diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 6e90c94..8c277c0 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -126,6 +126,13 @@ jobs: type=raw,value=${{ github.event.client_payload.new-tag }} type=raw,value=latest + - name: Capture Docker build metadata + id: docker_build_meta + run: | + echo "version=${{ github.event.client_payload.new-tag }}" >> "$GITHUB_OUTPUT" + echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Build and push Docker images uses: docker/build-push-action@v6 with: @@ -134,5 +141,9 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APP_VERSION=${{ steps.docker_build_meta.outputs.version }} + APP_COMMIT=${{ steps.docker_build_meta.outputs.commit }} + APP_DATE=${{ steps.docker_build_meta.outputs.date }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/AGENTS.md b/AGENTS.md index 8f718b7..622aa75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,11 +12,11 @@ Public documentation: `openapi-changes` is a Go CLI for comparing OpenAPI specifications across: -- direct left/right file or URL comparison +- direct left/right file, URL, or git-revision comparison - local git history for a file in a repository - GitHub-hosted file history via file URL -The public command surface is: +The public comparison/report surface is: - `console` - `summary` @@ -24,10 +24,15 @@ The public command surface is: - `markdown-report` - `html-report` -All five commands use the current engine built on `doctor`, the open source Apache 2.0 library from pb33f: +These five commands use the current engine built on `doctor`, the open source Apache 2.0 library from pb33f: - https://github.com/pb33f/doctor +Utility commands: + +- `completion` +- `version` + ## Source Of Truth The code is the source of truth. @@ -65,6 +70,7 @@ All `cmd/` implementation files use their canonical names (e.g., `cmd/summary.go - Left/right comparisons are synthetic comparisons, not fake git history. - Do not emit synthetic commit metadata in left/right machine- or human-facing report output. +- Git revision inputs (`revision:path`) resolve `$ref` siblings from the same revision via `GitRevisionFS`, not from the working tree. ### Failure semantics @@ -89,6 +95,7 @@ All `cmd/` implementation files use their canonical names (e.g., `cmd/summary.go ## Files That Matter Most - `cmd/root.go` — root Cobra command, CLI entry point, subcommand registration +- `cmd/version.go` — prints the raw build version string - `cmd/common.go` — shared option flags and Lip Gloss styling for all doctor-based commands - `cmd/loaders.go` — loads specs from files, URLs, and git history; progress tracking and error aggregation - `cmd/engine.go` — wraps doctor changerator for API comparison; manages document resource cleanup and mutex-guarded breaking config @@ -99,7 +106,9 @@ All `cmd/` implementation files use their canonical names (e.g., `cmd/summary.go - `cmd/console.go` — launches the interactive Bubbletea terminal UI - `cmd/flatten_report.go` — flattens hierarchical change reports into flat structures with hashed changes and normalized paths - `cmd/report_common.go` — shared utilities: summary report creation from commits, formatted report file writing +- `cmd/left_right_sources.go` — resolves local, URL, and git-revision inputs into uniform comparison sources with proper document configuration - `git/read_local.go` — local git history extraction via git commands; commit and file content preparation +- `git/revision_fs.go` — virtual filesystem that reads files from a git revision for `$ref` resolution in revision-scoped comparisons - `git/github.go` — remote file history fetching from GitHub repos via doctor GitHub service - `html-report/generator.go` — renders self-contained HTML using embedded templates and syntax-highlighted code - `model/report.go` — hashed change structures with SHA256 hashes and raw paths for serialization diff --git a/Dockerfile b/Dockerfile index c1bc3ee..01d1e92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS ui-builder WORKDIR /opt/openapi-changes/html-report/ui -COPY html-report/ui/package.json html-report/ui/package-lock.json ./ +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 ./ +COPY html-report/ui/src ./src RUN npm ci -COPY html-report/ui/ ./ RUN npm run build FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder @@ -12,6 +12,9 @@ ARG TARGETPLATFORM ARG BUILDPLATFORM ARG TARGETOS ARG TARGETARCH +ARG APP_VERSION=dev +ARG APP_COMMIT=unknown +ARG APP_DATE=1970-01-01T00:00:00Z RUN mkdir -p /opt/openapi-changes @@ -21,10 +24,15 @@ COPY . ./ COPY --from=ui-builder /opt/openapi-changes/html-report/ui/build/ html-report/ui/build/ RUN go mod download && go mod verify -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s" -v -o /openapi-changes openapi-changes.go +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \ + -ldflags="-w -s -X main.version=${APP_VERSION} -X main.commit=${APP_COMMIT} -X main.date=${APP_DATE}" \ + -v -o /openapi-changes openapi-changes.go FROM --platform=$TARGETPLATFORM debian:bookworm-slim -RUN apt-get update && apt-get --yes install git && rm -rf /var/lib/apt/lists/* +RUN apt-get update \ + && apt-get --yes install git \ + && git config --system --add safe.directory '*' \ + && rm -rf /var/lib/apt/lists/* WORKDIR /work COPY --from=builder /openapi-changes / diff --git a/README.md b/README.md index 81ef38b..0b6fc11 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ ## The world's **_most powerful and complete_** OpenAPI diff tool. -`openapi-changes` lets you inspect what changed in an OpenAPI specification between two files, -across local git history, or directly from a GitHub-hosted file URL. +`openapi-changes` lets you inspect what changed in an OpenAPI 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: @@ -63,6 +63,8 @@ docker pull pb33f/openapi-changes Docker images are available for both `linux/amd64` and `linux/arm64`. +The published image configures Git to trust mounted repositories, so local git-history commands work without requiring extra `safe.directory` setup inside the container. + To run a command, mount the current working directory into the container: ```bash @@ -75,6 +77,13 @@ To run the interactive `console` through Docker, allocate a TTY with `-it`: docker run --rm -it -v $PWD:/work:rw pb33f/openapi-changes console . path/to/openapi.yaml ``` +## Verify the install + +Print the installed version: + +```bash +openapi-changes version +``` --- @@ -128,6 +137,21 @@ A self-contained, offline HTML report with interactive timeline, change explorer --- +## Comparing git revisions + +Compare a file at different git revisions without checking out branches: + +```bash +openapi-changes summary HEAD~1:openapi.yaml ./openapi.yaml +openapi-changes html-report main:api/openapi.yaml feature-branch:api/openapi.yaml +``` + +The `revision:path` syntax works with any git ref -- branches, tags, `HEAD~N`, commit SHAs. +The path is relative to the repository root. This works with all commands and supports +multi-file specs with `$ref` references resolved from the same revision. + +--- + ## Documentation ### [Quick Start Guide 🚀](https://pb33f.io/openapi-changes/quickstart/) @@ -144,6 +168,7 @@ Full docs: https://pb33f.io/openapi-changes/ - [`markdown-report`](https://pb33f.io/openapi-changes/markdown-report/) - [`html-report`](https://pb33f.io/openapi-changes/html-report/) - [`completion`](https://pb33f.io/openapi-changes/completion/) + - [`version`](https://pb33f.io/openapi-changes/version/) - [About openapi-changes](https://pb33f.io/openapi-changes/about/) --- @@ -176,6 +201,7 @@ The current command surface is: - `markdown-report` for shareable markdown output - `html-report` for the interactive offline browser report - `completion` for shell completion scripts +- `version` for raw build version output Run `openapi-changes --help` or `openapi-changes --help` for the live CLI surface. diff --git a/cmd/command_surface_test.go b/cmd/command_surface_test.go index 075fb07..30e31e3 100644 --- a/cmd/command_surface_test.go +++ b/cmd/command_surface_test.go @@ -15,4 +15,5 @@ func TestCommandSurface_UsesCanonicalNames(t *testing.T) { assert.Equal(t, "markdown-report", GetMarkdownReportCommand().Use) assert.Equal(t, "html-report", GetHTMLReportCommand().Use) assert.Equal(t, "console", GetConsoleCommand().Use) + assert.Equal(t, "version", GetVersionCommand().Use) } diff --git a/cmd/common.go b/cmd/common.go index 73b606e..e303d39 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -8,7 +8,6 @@ import ( "image/color" "net/url" "os" - "strings" "charm.land/lipgloss/v2" "github.com/pb33f/doctor/terminal" @@ -209,18 +208,23 @@ func loadCommitsFromArgs(args []string, opts summaryOpts, breakingConfig *whatCh if len(args) == 1 { return loadGitHubCommits(args[0], opts, breakingConfig) } - firstURL, _ := url.Parse(args[0]) - if firstURL != nil && strings.HasPrefix(firstURL.Scheme, "http") { - return loadLeftRightCommits(args[0], args[1], opts, breakingConfig) + if isHTTPURL(args[0]) { + return loadLeftRightCommits(args[0], args[1], opts) + } + if _, _, ok := parseGitRef(args[0]); ok { + return loadLeftRightCommits(args[0], args[1], opts) } f, statErr := os.Stat(args[0]) + if statErr == nil && f.IsDir() { + return loadGitHistoryCommits(args[0], args[1], opts, breakingConfig) + } + if _, _, ok := parseGitRef(args[1]); ok { + return loadLeftRightCommits(args[0], args[1], opts) + } if statErr != nil { return nil, fmt.Errorf("cannot open file/repository: '%s'", args[0]) } - if f.IsDir() { - return loadGitHistoryCommits(args[0], args[1], opts, breakingConfig) - } - return loadLeftRightCommits(args[0], args[1], opts, breakingConfig) + return loadLeftRightCommits(args[0], args[1], opts) } // printCommandUsage prints lipgloss-styled usage for any doctor-based command. @@ -239,6 +243,7 @@ func printCommandUsage(commandName, description string, palette terminal.Palette fmt.Println() fmt.Println("Examples:") fmt.Printf(" %s\n", cmdStyle.Render("openapi-changes "+commandName+" ./specs/old.yaml ./specs/new.yaml")) + fmt.Printf(" %s\n", cmdStyle.Render("openapi-changes "+commandName+" HEAD~1:openapi.yaml ./openapi.yaml")) fmt.Printf(" %s\n", cmdStyle.Render("openapi-changes "+commandName+" https://github.com/user/repo/blob/main/openapi.yaml")) fmt.Printf(" %s\n", cmdStyle.Render("openapi-changes "+commandName+" /path/to/git/repo path/to/openapi.yaml")) fmt.Println() diff --git a/cmd/console.go b/cmd/console.go index 34d9591..52cbc04 100644 --- a/cmd/console.go +++ b/cmd/console.go @@ -60,7 +60,7 @@ func GetConsoleCommand() *cobra.Command { Use: "console", Short: "Interactive terminal UI for exploring changes", Long: "Navigate through changes visually in an interactive terminal UI built with Bubbletea, using the doctor changerator engine.", - Example: "openapi-changes console /path/to/git/repo path/to/file/in/repo/openapi.yaml", + Example: "openapi-changes console HEAD~1:openapi.yaml ./openapi.yaml", RunE: func(cmd *cobra.Command, args []string) error { input, err := prepareCommandRun(cmd, args, printConsoleUsage) if err != nil { diff --git a/cmd/engine.go b/cmd/engine.go index 87218cf..a1f4eb5 100644 --- a/cmd/engine.go +++ b/cmd/engine.go @@ -9,12 +9,12 @@ import ( "github.com/pb33f/doctor/changerator" drModel "github.com/pb33f/doctor/model" + v3 "github.com/pb33f/doctor/model/high/v3" whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/openapi-changes/internal/breakingrules" "github.com/pb33f/openapi-changes/model" ) - // changeratorResult owns the doctor-side resources created for a single comparison. // // Callers must call Release() exactly once when they are done reading Changerator, @@ -45,11 +45,11 @@ func runChangerator(commit *model.Commit, breakingConfig *whatChangedModel.Break rightModel, err := commit.Document.BuildV3Model() if err != nil { - return nil, fmt.Errorf("building right model: %w", err) + return nil, modelBuildError("modified", commitSourceLabel(commit, true), err) } leftModel, err := commit.OldDocument.BuildV3Model() if err != nil { - return nil, fmt.Errorf("building left model: %w", err) + return nil, modelBuildError("original", commitSourceLabel(commit, false), err) } rightDrDoc := drModel.NewDrDocumentAndGraph(rightModel) @@ -82,6 +82,7 @@ func runChangerator(commit *model.Commit, breakingConfig *whatChangedModel.Break leftDrDoc.Release() return nil, nil } + rewriteOutputLocations(ctr, docChanges, commit.DocumentRewriters) return &changeratorResult{ Changerator: ctr, @@ -91,6 +92,161 @@ func runChangerator(commit *model.Commit, breakingConfig *whatChangedModel.Break }, nil } -func emitCommitWarning(commitHash string, err error) { - fmt.Fprintf(os.Stderr, "warning: commit %s: %s\n", commitHash, err) +func rewriteOutputLocations(ctr *changerator.Changerator, docChanges *whatChangedModel.DocumentChanges, rewriters []model.DocumentPathRewriter) { + if len(rewriters) == 0 { + return + } + rewriteDocumentChangeLocations(docChanges, rewriters) + if ctr != nil { + rewriteChangedNodeLocations(ctr.ChangedNodes, rewriters) + } +} + +func rewriteDocumentChangeLocations(docChanges *whatChangedModel.DocumentChanges, rewriters []model.DocumentPathRewriter) { + if docChanges == nil { + return + } + for _, change := range docChanges.GetAllChanges() { + if change == nil || change.Context == nil || change.Context.DocumentLocation == "" { + continue + } + change.Context.DocumentLocation = rewriteDocumentLocation(change.Context.DocumentLocation, rewriters) + } +} + +func rewriteChangedNodeLocations(nodes []*v3.Node, rewriters []model.DocumentPathRewriter) { + if len(nodes) == 0 { + return + } + seen := make(map[*v3.Node]struct{}, len(nodes)) + var walk func(*v3.Node) + walk = func(node *v3.Node) { + if node == nil { + return + } + if _, ok := seen[node]; ok { + return + } + seen[node] = struct{}{} + + if node.Origin != nil { + node.Origin.AbsoluteLocation = rewriteDocumentLocation(node.Origin.AbsoluteLocation, rewriters) + node.Origin.AbsoluteLocationValue = rewriteDocumentLocation(node.Origin.AbsoluteLocationValue, rewriters) + } + node.OriginLocation = rewriteDocumentLocation(node.OriginLocation, rewriters) + for _, changed := range node.GetChanges() { + for _, change := range changed.GetAllChanges() { + if change == nil || change.Context == nil { + continue + } + change.Context.DocumentLocation = rewriteDocumentLocation(change.Context.DocumentLocation, rewriters) + } + } + for _, change := range node.RenderedChanges { + if change == nil || change.Context == nil { + continue + } + change.Context.DocumentLocation = rewriteDocumentLocation(change.Context.DocumentLocation, rewriters) + } + for _, change := range node.CleanedChanged { + if change == nil || change.Context == nil { + continue + } + change.Context.DocumentLocation = rewriteDocumentLocation(change.Context.DocumentLocation, rewriters) + } + + for _, child := range node.Children { + walk(child) + } + } + for _, node := range nodes { + walk(node) + } +} + +func rewriteDocumentLocation(raw string, rewriters []model.DocumentPathRewriter) string { + if raw == "" { + return raw + } + for _, rewrite := range rewriters { + if rewrite == nil { + continue + } + rewritten := rewrite(raw) + if rewritten != raw { + return rewritten + } + } + return raw +} + +func modelBuildError(side, label string, err error) error { + if label == "" { + return fmt.Errorf("building %s model: %w", side, err) + } + return fmt.Errorf("building %s model '%s': %w", side, label, err) +} + +func commitSourceLabel(commit *model.Commit, modified bool) string { + if commit == nil { + return "" + } + if modified { + if commit.ModifiedSource != "" { + return commit.ModifiedSource + } + if commit.FilePath != "" { + return commit.FilePath + } + return "" + } + if commit.OriginalSource != "" { + return commit.OriginalSource + } + if commit.FilePath != "" { + return commit.FilePath + } + return "" +} + +func commitContextLabel(commit *model.Commit) string { + if commit == nil { + return "" + } + if commit.Synthetic { + switch { + case commit.OriginalSource != "" && commit.ModifiedSource != "": + return fmt.Sprintf("comparison '%s' -> '%s'", commit.OriginalSource, commit.ModifiedSource) + case commit.ModifiedSource != "": + return fmt.Sprintf("comparison '%s'", commit.ModifiedSource) + case commit.OriginalSource != "": + return fmt.Sprintf("comparison '%s'", commit.OriginalSource) + } + } + if commit.Hash != "" && commit.FilePath != "" { + return fmt.Sprintf("commit %s (%s)", commit.Hash, commit.FilePath) + } + if commit.Hash != "" { + return fmt.Sprintf("commit %s", commit.Hash) + } + if commit.FilePath != "" { + return fmt.Sprintf("file '%s'", commit.FilePath) + } + return "" +} + +func wrapCommitError(commit *model.Commit, err error) error { + if err == nil { + return nil + } + if label := commitContextLabel(commit); label != "" { + return fmt.Errorf("%s: %w", label, err) + } + return err +} + +func emitCommitWarning(commit *model.Commit, err error) { + if wrapped := wrapCommitError(commit, err); wrapped != nil { + fmt.Fprintf(os.Stderr, "warning: %s\n", wrapped) + } } diff --git a/cmd/engine_test.go b/cmd/engine_test.go new file mode 100644 index 0000000..b3b1e20 --- /dev/null +++ b/cmd/engine_test.go @@ -0,0 +1,89 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "path/filepath" + "strings" + "testing" + + v3 "github.com/pb33f/doctor/model/high/v3" + "github.com/pb33f/openapi-changes/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunChangerator_RewritesChangedNodeOriginsToRepoRelativePaths(t *testing.T) { + repoDir, fileName := createMovedRefGitSpecRepo(t) + + commits, err := loadGitHistoryCommits(repoDir, fileName, summaryOpts{base: repoDir, noColor: true, limitTime: -1}, nil) + require.NoError(t, err) + require.NotEmpty(t, commits) + + target := firstComparableCommit(commits) + require.NotNil(t, target) + + result, err := runChangerator(target, nil) + require.NoError(t, err) + require.NotNil(t, result) + defer result.Release() + + origins := collectChangedNodeOrigins(result.Changerator.ChangedNodes) + require.NotEmpty(t, origins) + + hasRepoRelativeOrigin := false + for _, origin := range origins { + assert.NotContains(t, origin, repoDir) + assert.False(t, filepath.IsAbs(filepath.FromSlash(origin)), "origin should be repo-relative: %s", origin) + if origin == "spec.yaml" || strings.HasPrefix(origin, "common") { + hasRepoRelativeOrigin = true + } + } + assert.True(t, hasRepoRelativeOrigin, "expected at least one repo-relative origin, got %v", origins) +} + +func firstComparableCommit(commits []*model.Commit) *model.Commit { + for _, commit := range commits { + if commit != nil && commit.Document != nil && commit.OldDocument != nil { + return commit + } + } + return nil +} + +func collectChangedNodeOrigins(nodes []*v3.Node) []string { + seen := make(map[*v3.Node]struct{}, len(nodes)) + var origins []string + + var walk func(*v3.Node) + walk = func(node *v3.Node) { + if node == nil { + return + } + if _, ok := seen[node]; ok { + return + } + seen[node] = struct{}{} + + if node.Origin != nil { + if node.Origin.AbsoluteLocation != "" { + origins = append(origins, node.Origin.AbsoluteLocation) + } + if node.Origin.AbsoluteLocationValue != "" { + origins = append(origins, node.Origin.AbsoluteLocationValue) + } + } + if node.OriginLocation != "" { + origins = append(origins, node.OriginLocation) + } + for _, child := range node.Children { + walk(child) + } + } + + for _, node := range nodes { + walk(node) + } + return origins +} diff --git a/cmd/flag_surface_test.go b/cmd/flag_surface_test.go index ee78427..e544bb1 100644 --- a/cmd/flag_surface_test.go +++ b/cmd/flag_surface_test.go @@ -30,9 +30,10 @@ func TestCanonicalCommandLocalFlags(t *testing.T) { }, flagNames(GetSummaryCommand())) assert.Equal(t, map[string]bool{ - "no-color": true, - "roger-mode": true, - "tektronix": true, + "no-color": true, + "roger-mode": true, + "reproducible": true, + "tektronix": true, }, flagNames(GetReportCommand())) assert.Equal(t, map[string]bool{ diff --git a/cmd/flatten_report.go b/cmd/flatten_report.go index 33fd5eb..5bb2c66 100644 --- a/cmd/flatten_report.go +++ b/cmd/flatten_report.go @@ -4,6 +4,8 @@ package cmd import ( + "cmp" + "sort" "strconv" "strings" "time" @@ -44,6 +46,7 @@ func flattenReport(report *model.Report, parameterNames map[string]string) *mode changes = append(changes, &hashedChange) } + sortFlatReportChanges(changes) flatReport.Changes = changes // Copy the Commit information from the report to the flatReport and then delete the changes @@ -120,3 +123,130 @@ func rawPathIfChanged(rawPath, semanticPath string) string { } return rawPath } + +func sortFlatReportChanges(changes []*model.HashedChange) { + sort.SliceStable(changes, func(i, j int) bool { + return compareHashedChanges(changes[i], changes[j]) < 0 + }) +} + +func compareHashedChanges(left, right *model.HashedChange) int { + if diff := cmp.Compare(changePath(left), changePath(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeRawPath(left), changeRawPath(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeType(left), changeType(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeProperty(left), changeProperty(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeKind(left), changeKind(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeBreaking(left), changeBreaking(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeOriginal(left), changeOriginal(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeNew(left), changeNew(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeOriginalEncoded(left), changeOriginalEncoded(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeNewEncoded(left), changeNewEncoded(right)); diff != 0 { + return diff + } + if diff := cmp.Compare(changeReference(left), changeReference(right)); diff != 0 { + return diff + } + return cmp.Compare(changeHash(left), changeHash(right)) +} + +func changePath(change *model.HashedChange) string { + if change == nil || change.Change == nil { + return "" + } + return change.Path +} + +func changeRawPath(change *model.HashedChange) string { + if change == nil { + return "" + } + return change.RawPath +} + +func changeType(change *model.HashedChange) string { + if change == nil || change.Change == nil { + return "" + } + return change.Type +} + +func changeProperty(change *model.HashedChange) string { + if change == nil || change.Change == nil { + return "" + } + return change.Property +} + +func changeKind(change *model.HashedChange) int { + if change == nil || change.Change == nil { + return 0 + } + return change.ChangeType +} + +func changeBreaking(change *model.HashedChange) int { + if change == nil || change.Change == nil || !change.Breaking { + return 0 + } + return 1 +} + +func changeOriginal(change *model.HashedChange) string { + if change == nil || change.Change == nil { + return "" + } + return change.Original +} + +func changeNew(change *model.HashedChange) string { + if change == nil || change.Change == nil { + return "" + } + return change.New +} + +func changeOriginalEncoded(change *model.HashedChange) string { + if change == nil || change.Change == nil { + return "" + } + return change.OriginalEncoded +} + +func changeNewEncoded(change *model.HashedChange) string { + if change == nil || change.Change == nil { + return "" + } + return change.NewEncoded +} + +func changeReference(change *model.HashedChange) string { + if change == nil || change.Change == nil { + return "" + } + return change.Reference +} + +func changeHash(change *model.HashedChange) string { + if change == nil { + return "" + } + return change.ChangeHash +} diff --git a/cmd/flatten_report_test.go b/cmd/flatten_report_test.go index 0e0ec82..a819e1d 100644 --- a/cmd/flatten_report_test.go +++ b/cmd/flatten_report_test.go @@ -7,6 +7,7 @@ import ( "testing" wcModel "github.com/pb33f/libopenapi/what-changed/model" + openapiModel "github.com/pb33f/openapi-changes/model" "github.com/stretchr/testify/assert" ) @@ -176,3 +177,46 @@ func TestRawPathIfChanged_SamePaths(t *testing.T) { func TestRawPathIfChanged_DifferentPaths(t *testing.T) { assert.Equal(t, "$.parameters[0]", rawPathIfChanged("$.parameters[0]", "$.parameters['petId']")) } + +func TestFlattenReport_SortsChangesDeterministically(t *testing.T) { + report := &openapiModel.Report{ + Commit: &openapiModel.Commit{ + Changes: &wcModel.DocumentChanges{ + PropertyChanges: wcModel.NewPropertyChanges([]*wcModel.Change{ + { + Context: &wcModel.ChangeContext{}, + ChangeType: wcModel.PropertyAdded, + Path: "$.paths['/pets'].post", + Property: "summary", + Type: "operation", + New: "create a pet", + }, + { + Context: &wcModel.ChangeContext{}, + ChangeType: wcModel.Modified, + Path: "$.info", + Property: "title", + Type: "info", + Original: "zeta", + New: "alpha", + }, + { + Context: &wcModel.ChangeContext{}, + ChangeType: wcModel.PropertyRemoved, + Path: "$.paths['/pets'].get", + Property: "description", + Type: "operation", + Original: "list pets", + }, + }), + }, + }, + } + + flat := FlattenReport(report) + + assert.Len(t, flat.Changes, 3) + assert.Equal(t, "$.info", flat.Changes[0].Path) + assert.Equal(t, "$.paths['/pets'].get", flat.Changes[1].Path) + assert.Equal(t, "$.paths['/pets'].post", flat.Changes[2].Path) +} diff --git a/cmd/html_report.go b/cmd/html_report.go index 4b8077a..58fa5f4 100644 --- a/cmd/html_report.go +++ b/cmd/html_report.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "strconv" "text/template" "time" @@ -38,8 +37,8 @@ func buildHTMLReportItems(commits []*model.Commit, breakingConfig *whatChangedMo for i, commit := range commits { result, err := runChangerator(commit, breakingConfig) if err != nil { - emitCommitWarning(commit.Hash, err) - buildErrors = append(buildErrors, err) + emitCommitWarning(commit, err) + buildErrors = append(buildErrors, wrapCommitError(commit, err)) continue } if result == nil { @@ -57,8 +56,9 @@ func buildHTMLReportItems(commits []*model.Commit, breakingConfig *whatChangedMo result.Release() if err != nil { - emitCommitWarning(commit.Hash, fmt.Errorf("building report item: %w", err)) - buildErrors = append(buildErrors, err) + wrappedErr := fmt.Errorf("building report item: %w", err) + emitCommitWarning(commit, wrappedErr) + buildErrors = append(buildErrors, wrapCommitError(commit, wrappedErr)) continue } @@ -94,10 +94,11 @@ func generateHTMLReport(commits []*model.Commit, breakingConfig *whatChangedMode History: history, } - // For left/right comparisons, include sanitized spec paths (basename only) + // For left/right comparisons, preserve explicit git-ref labels, sanitize URL + // labels, and keep plain local files compact. if len(args) == 2 && len(items) == 1 { - payload.OriginalPath = filepath.Base(args[0]) - payload.ModifiedPath = filepath.Base(args[1]) + payload.OriginalPath = displayLabelForHTML(args[0]) + payload.ModifiedPath = displayLabelForHTML(args[1]) } // json.Marshal escapes <, >, & by default — prevents injection. @@ -135,7 +136,7 @@ func GetHTMLReportCommand() *cobra.Command { Use: "html-report", Short: "Generate an interactive HTML report", Long: "Generate a rich, interactive HTML report. The report is fully self-contained and works offline.", - Example: "openapi-changes html-report /path/to/git/repo path/to/file/in/repo/openapi.yaml", + Example: "openapi-changes html-report HEAD~1:openapi.yaml ./openapi.yaml", RunE: func(cmd *cobra.Command, args []string) error { input, err := prepareCommandRun(cmd, args, printHTMLReportUsage) if err != nil { diff --git a/cmd/html_report_test.go b/cmd/html_report_test.go index 2743dfb..f11717e 100644 --- a/cmd/html_report_test.go +++ b/cmd/html_report_test.go @@ -5,10 +5,13 @@ package cmd import ( "encoding/json" + "net/http" + "net/http/httptest" "path/filepath" "strings" "testing" + whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" htmlReport "github.com/pb33f/openapi-changes/html-report" "github.com/pb33f/openapi-changes/model" "github.com/stretchr/testify/assert" @@ -20,7 +23,6 @@ func TestGenerateHTMLReport_UnchangedLeftRight(t *testing.T) { "../sample-specs/petstorev3.json", "../sample-specs/petstorev3.json", summaryOpts{noColor: true}, - nil, ) require.NoError(t, err) @@ -37,7 +39,6 @@ func TestGenerateHTMLReport_LeftRightIncludesSanitizedPaths(t *testing.T) { "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", summaryOpts{noColor: true}, - nil, ) require.NoError(t, err) @@ -54,6 +55,74 @@ func TestGenerateHTMLReport_LeftRightIncludesSanitizedPaths(t *testing.T) { assert.Contains(t, content, "= http.StatusMultipleChoices { + return comparisonSource{}, fmt.Errorf("error downloading file '%s': unexpected status %s", display, resp.Status) + } + + const maxDownloadSize = 100 << 20 // 100 MB + bits, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize)) + if err != nil { + return comparisonSource{}, fmt.Errorf("error reading downloaded file '%s': %w", display, err) + } + if len(bits) == 0 { + return comparisonSource{}, fmt.Errorf("downloaded file '%s' is empty", display) + } + + baseURL, err := url.Parse(raw) + if err != nil { + return comparisonSource{}, fmt.Errorf("invalid URL '%s': %w", display, err) + } + baseURL.Path = path.Dir(baseURL.Path) + baseURL.RawQuery = "" + baseURL.Fragment = "" + + docConfig := newComparisonDocConfig(opts) + docConfig.BaseURL = baseURL + docConfig.AllowRemoteReferences = true + docConfig.UseSchemaQuickHash = true + + if basePathOverride, baseURLOverride, err := resolveBaseOverride(opts.base); err != nil { + return comparisonSource{}, err + } else { + if basePathOverride != "" { + docConfig.AllowFileReferences = true + docConfig.BasePath = basePathOverride + } + if baseURLOverride != nil { + docConfig.BaseURL = baseURLOverride + } + } + if err := attachLazyLocalFS(docConfig); err != nil { + return comparisonSource{}, err + } + + return comparisonSource{ + Display: display, + RootBytes: bits, + DocConfig: docConfig, + RewriteDocumentPath: nil, + Cleanup: func() {}, + }, nil +} + +func sanitizeURLLabel(raw string) string { + if !isHTTPURL(raw) { + return raw + } + + parsed, err := url.Parse(raw) + if err != nil || parsed == nil { + return raw + } + parsed.User = nil + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() +} + +func attachLazyLocalFS(docConfig *datamodel.DocumentConfiguration) error { + if docConfig == nil || docConfig.BasePath == "" || !docConfig.AllowFileReferences || docConfig.LocalFS != nil { + return nil + } + + localFS, err := index.NewLocalFSWithConfig(&index.LocalFSConfig{ + BaseDirectory: docConfig.BasePath, + IndexConfig: &index.SpecIndexConfig{ + BaseURL: docConfig.BaseURL, + BasePath: docConfig.BasePath, + SpecFilePath: docConfig.SpecFilePath, + AllowRemoteLookup: docConfig.AllowRemoteReferences, + AllowFileLookup: true, + IgnorePolymorphicCircularReferences: docConfig.IgnorePolymorphicCircularReferences, + IgnoreArrayCircularReferences: docConfig.IgnoreArrayCircularReferences, + AvoidCircularReferenceCheck: docConfig.SkipCircularReferenceCheck, + UseSchemaQuickHash: true, + Logger: docConfig.Logger, + }, + }) + if err != nil { + return fmt.Errorf("cannot create local filesystem for base path '%s': %w", docConfig.BasePath, err) + } + docConfig.LocalFS = localFS + return nil +} + +func buildLeftRightCommit(left, right comparisonSource) (*model.Commit, error) { + leftDoc, err := libopenapi.NewDocumentWithConfiguration(left.RootBytes, left.DocConfig) + if err != nil { + return nil, fmt.Errorf("unable to parse original document '%s': %w", left.Display, err) + } + rightDoc, err := libopenapi.NewDocumentWithConfiguration(right.RootBytes, right.DocConfig) + if err != nil { + leftDoc.Release() + return nil, fmt.Errorf("unable to parse modified document '%s': %w", right.Display, err) + } + + var rewriters []model.DocumentPathRewriter + if left.RewriteDocumentPath != nil { + rewriters = append(rewriters, left.RewriteDocumentPath) + } + if right.RewriteDocumentPath != nil { + rewriters = append(rewriters, right.RewriteDocumentPath) + } + + return &model.Commit{ + Hash: uuid.New().String()[:6], + Message: fmt.Sprintf("Original: %s, Modified: %s", left.Display, right.Display), + CommitDate: time.Now(), + OldData: left.RootBytes, + Data: right.RootBytes, + OldDocument: leftDoc, + Document: rightDoc, + OriginalSource: left.Display, + ModifiedSource: right.Display, + Synthetic: true, + DocumentRewriters: rewriters, + }, nil +} + +func buildLeftRightCommitAndSources(left, right string, opts summaryOpts) (*model.Commit, error) { + leftSource, err := resolveComparisonSource(left, opts) + if err != nil { + return nil, err + } + defer leftSource.Cleanup() + + rightSource, err := resolveComparisonSource(right, opts) + if err != nil { + return nil, err + } + defer rightSource.Cleanup() + + commit, err := buildLeftRightCommit(leftSource, rightSource) + if err != nil { + return nil, err + } + return commit, nil +} diff --git a/cmd/loaders.go b/cmd/loaders.go index 6eb8d88..b59381c 100644 --- a/cmd/loaders.go +++ b/cmd/loaders.go @@ -6,16 +6,13 @@ package cmd import ( "errors" "fmt" - "io" "net/http" "net/url" "os" "path/filepath" "strings" "sync" - "time" - "github.com/google/uuid" whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/openapi-changes/git" "github.com/pb33f/openapi-changes/model" @@ -24,7 +21,6 @@ import ( var processGithubRepo = git.ProcessGithubRepo var extractHistoryFromFile = git.ExtractHistoryFromFile var populateHistory = git.PopulateHistory -var buildChangelog = git.BuildChangelog var httpGet = http.Get // progressDrainer drains git progress channels that use synchronous sends. @@ -203,103 +199,97 @@ func loadGitHistoryCommits(gitPath, filePath string, opts summaryOpts, breakingC return commits, nil } -func loadLeftRightCommits(left, right string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) ([]*model.Commit, error) { - d := makeProgressDrainer() - defer d.close() - - var err error - left, leftCleanup, err := resolveLeftRightInput(left) +func loadLeftRightCommits(left, right string, opts summaryOpts) ([]*model.Commit, error) { + commit, err := buildLeftRightCommitAndSources(left, right, opts) if err != nil { return nil, err } - defer leftCleanup() + return []*model.Commit{commit}, nil +} - right, rightCleanup, err := resolveLeftRightInput(right) - if err != nil { - return nil, err +func parseGitRef(raw string) (revision, filePath string, ok bool) { + if isHTTPURL(raw) { + return "", "", false } - defer rightCleanup() - - leftBytes, err := os.ReadFile(left) - if err != nil { - return nil, fmt.Errorf("cannot read original spec: %w", err) + if shouldPreferLocalPath(raw) { + return "", "", false } - rightBytes, err := os.ReadFile(right) - if err != nil { - return nil, fmt.Errorf("cannot read modified spec: %w", err) + colonIdx := strings.IndexByte(raw, ':') + if colonIdx < 0 { + return "", "", false } - - commits := []*model.Commit{ - { - Hash: uuid.New().String()[:6], - Message: fmt.Sprintf("Original: %s, Modified: %s", left, right), - CommitDate: time.Now(), - Data: rightBytes, - Synthetic: true, - }, - { - Hash: uuid.New().String()[:6], - Message: fmt.Sprintf("Original file: %s", left), - CommitDate: time.Now(), - Data: leftBytes, - Synthetic: true, - }, + if colonIdx == 1 && len(raw) > 1 && ((raw[0] >= 'A' && raw[0] <= 'Z') || (raw[0] >= 'a' && raw[0] <= 'z')) { + return "", "", false } + revision = raw[:colonIdx] + filePath = raw[colonIdx+1:] + if revision == "" || filePath == "" { + return "", "", false + } + return revision, filePath, true +} - commits, errs := buildChangelog(commits, d.ProgressChan, d.ErrorChan, - git.HistoryOptions{ - Base: opts.base, - Remote: opts.remote, - ExtRefs: opts.extRefs, - KeepComparable: true, - }, breakingConfig) - if err := d.collectErrors(errs); err != nil { - return nil, err +func shouldPreferLocalPath(raw string) bool { + if raw == "" { + return false } - return commits, nil + if filepath.IsAbs(raw) || strings.HasPrefix(raw, "."+string(filepath.Separator)) || strings.HasPrefix(raw, ".."+string(filepath.Separator)) { + return true + } + _, err := os.Lstat(raw) + return err == nil } -func resolveLeftRightInput(raw string) (string, func(), error) { - specURL, err := url.Parse(raw) - if err != nil || specURL == nil || !strings.HasPrefix(specURL.Scheme, "http") { - return raw, func() {}, nil +func isHTTPURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u == nil { + return false } + return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} - resp, err := httpGet(raw) +func normalizeGitRefPath(repoRoot, filePath string) (string, error) { + canonicalRepoRoot, err := git.CanonicalizePath(repoRoot) if err != nil { - return "", func() {}, fmt.Errorf("error downloading file '%s': %w", raw, err) + return "", fmt.Errorf("cannot canonicalize repository root '%s': %w", repoRoot, err) } - defer resp.Body.Close() - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return "", func() {}, fmt.Errorf("error downloading file '%s': unexpected status %s", raw, resp.Status) + var absPath string + if filepath.IsAbs(filePath) { + absPath, err = git.CanonicalizePath(filePath) + if err != nil { + return "", fmt.Errorf("cannot canonicalize git ref path '%s': %w", filePath, err) + } + } else { + absPath = filepath.Join(canonicalRepoRoot, filepath.Clean(filePath)) } - const maxDownloadSize = 100 << 20 // 100 MB - bits, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize)) + relPath, err := filepath.Rel(canonicalRepoRoot, absPath) if err != nil { - return "", func() {}, fmt.Errorf("error reading downloaded file '%s': %w", raw, err) + return "", fmt.Errorf("cannot normalize git ref path '%s': %w", filePath, err) } - if len(bits) == 0 { - return "", func() {}, fmt.Errorf("downloaded file '%s' is empty", raw) + if relPath == "." || relPath == "" { + return "", fmt.Errorf("git ref path '%s' must point to a file", filePath) } - - tmpFile, err := os.CreateTemp("", "openapi-changes-*") - if err != nil { - return "", func() {}, fmt.Errorf("cannot create temp file for '%s': %w", raw, err) + if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("git ref path '%s' resolves outside the current repository", filePath) } - if _, err = tmpFile.Write(bits); err != nil { - tmpName := tmpFile.Name() - _ = tmpFile.Close() - _ = os.Remove(tmpName) - return "", func() {}, fmt.Errorf("downloaded file '%s' cannot be written: %w", raw, err) + return filepath.ToSlash(relPath), nil +} + +func displayLabelForHTML(raw string) string { + if _, _, ok := parseGitRef(raw); ok { + return raw } - if err = tmpFile.Close(); err != nil { - tmpName := tmpFile.Name() - _ = os.Remove(tmpName) - return "", func() {}, fmt.Errorf("downloaded file '%s' cannot be closed: %w", raw, err) + if isHTTPURL(raw) { + return sanitizeURLLabel(raw) } + return filepath.Base(raw) +} - tmpName := tmpFile.Name() - return tmpName, func() { _ = os.Remove(tmpName) }, nil +func sourceLabelForReport(raw string) string { + if isHTTPURL(raw) { + return sanitizeURLLabel(raw) + } + return raw } diff --git a/cmd/loaders_test.go b/cmd/loaders_test.go new file mode 100644 index 0000000..b4562fe --- /dev/null +++ b/cmd/loaders_test.go @@ -0,0 +1,336 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/openapi-changes/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseGitRef(t *testing.T) { + tests := []struct { + name string + raw string + revision string + filePath string + ok bool + }{ + {name: "branch", raw: "main:openapi.yaml", revision: "main", filePath: "openapi.yaml", ok: true}, + {name: "head relative", raw: "HEAD~1:spec.yaml", revision: "HEAD~1", filePath: "spec.yaml", ok: true}, + {name: "tag", raw: "v1.0.0:path/to/spec.yaml", revision: "v1.0.0", filePath: "path/to/spec.yaml", ok: true}, + {name: "remote branch", raw: "origin/main:spec.yaml", revision: "origin/main", filePath: "spec.yaml", ok: true}, + {name: "http-looking git ref", raw: "http-fix:openapi.yaml", revision: "http-fix", filePath: "openapi.yaml", ok: true}, + {name: "https-looking git ref", raw: "https-cleanup:spec.yaml", revision: "https-cleanup", filePath: "spec.yaml", ok: true}, + {name: "url", raw: "https://example.com/spec.yaml", ok: false}, + {name: "local file", raw: "./local/file.yaml", ok: false}, + {name: "windows path", raw: `C:\path\spec.yaml`, ok: false}, + {name: "empty revision", raw: ":path.yaml", ok: false}, + {name: "empty path", raw: "main:", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + revision, filePath, ok := parseGitRef(tt.raw) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.revision, revision) + assert.Equal(t, tt.filePath, filePath) + }) + } +} + +func TestParseGitRef_ExistingLocalColonPathIsNotGitRef(t *testing.T) { + dir := t.TempDir() + chdirForTest(t, dir) + + path := "v1:beta.yaml" + require.NoError(t, os.WriteFile(path, []byte("openapi: 3.0.3\ninfo:\n title: test\n version: '1.0'\npaths: {}\n"), 0o644)) + + revision, filePath, ok := parseGitRef(path) + assert.False(t, ok) + assert.Equal(t, "", revision) + assert.Equal(t, "", filePath) +} + +func TestNormalizeGitRefPath_FromRepoRoot(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + repoRoot := filepath.Clean(filepath.Join(wd, "..")) + + got, err := normalizeGitRefPath(repoRoot, "sample-specs/petstorev3.json") + require.NoError(t, err) + assert.Equal(t, "sample-specs/petstorev3.json", got) +} + +func TestNormalizeGitRefPath_FromSubdirectory(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + repoRoot := filepath.Clean(filepath.Join(wd, "..")) + + got, err := normalizeGitRefPath(repoRoot, "sample-specs/petstorev3.json") + require.NoError(t, err) + assert.Equal(t, "sample-specs/petstorev3.json", got) +} + +func TestNormalizeGitRefPath_AbsolutePathInsideRepo(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + repoRoot := filepath.Clean(filepath.Join(wd, "..")) + absPath := filepath.Join(repoRoot, "sample-specs", "petstorev3.json") + + got, err := normalizeGitRefPath(repoRoot, absPath) + require.NoError(t, err) + assert.Equal(t, "sample-specs/petstorev3.json", got) +} + +func TestNormalizeGitRefPath_SymlinkedWorkingTreeAlias(t *testing.T) { + parentDir := t.TempDir() + repoRoot := filepath.Join(parentDir, "repo") + require.NoError(t, os.Mkdir(repoRoot, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "openapi.yaml"), []byte("openapi: 3.0.3\npaths: {}\n"), 0o644)) + + linkPath := filepath.Join(parentDir, "repo-link") + err := os.Symlink(repoRoot, linkPath) + if err != nil { + t.Skipf("symlink not supported: %v", err) + } + + got, err := normalizeGitRefPath(repoRoot, filepath.Join(linkPath, "openapi.yaml")) + require.NoError(t, err) + assert.Equal(t, "openapi.yaml", got) +} + +func TestNormalizeGitRefPath_MissingPathUnderSymlinkedAliasStaysInsideRepo(t *testing.T) { + parentDir := t.TempDir() + repoRoot := filepath.Join(parentDir, "repo") + require.NoError(t, os.Mkdir(repoRoot, 0o755)) + require.NoError(t, os.Mkdir(filepath.Join(repoRoot, "nested"), 0o755)) + + linkPath := filepath.Join(parentDir, "repo-link") + err := os.Symlink(repoRoot, linkPath) + if err != nil { + t.Skipf("symlink not supported: %v", err) + } + + got, err := normalizeGitRefPath(repoRoot, filepath.Join(linkPath, "nested", "missing.yaml")) + require.NoError(t, err) + assert.Equal(t, "nested/missing.yaml", got) +} + +func TestNormalizeGitRefPath_RejectsOutsideRepo(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + repoRoot := filepath.Clean(filepath.Join(wd, "..")) + + _, err = normalizeGitRefPath(repoRoot, "../../outside.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "resolves outside the current repository") +} + +func TestResolveGitRefSource(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + repoRoot := filepath.Clean(filepath.Join(wd, "..")) + chdirForTest(t, repoRoot) + + source, err := resolveGitRefSource("HEAD:go.mod", "HEAD", "go.mod", summaryOpts{}) + require.NoError(t, err) + assert.Equal(t, "HEAD:go.mod", source.Display) + assert.NotEmpty(t, source.RootBytes) + require.NotNil(t, source.DocConfig) + assert.True(t, strings.HasSuffix(source.DocConfig.SpecFilePath, string(filepath.Separator)+"go.mod")) + assert.NotNil(t, source.DocConfig.LocalFS) +} + +func TestResolveGitRefSource_FromSubdirectoryUsesRepoRootRelativePath(t *testing.T) { + repoDir := createGitSpecRepo(t) + subDir := filepath.Join(repoDir, "child") + require.NoError(t, os.Mkdir(subDir, 0o755)) + chdirForTest(t, subDir) + + source, err := resolveGitRefSource("HEAD:openapi.yaml", "HEAD", "openapi.yaml", summaryOpts{}) + require.NoError(t, err) + assert.Equal(t, "HEAD:openapi.yaml", source.Display) + assert.NotEmpty(t, source.RootBytes) + assert.True(t, strings.HasSuffix(source.DocConfig.SpecFilePath, string(filepath.Separator)+"openapi.yaml")) +} + +func TestResolveGitRefSource_AbsolutePathInsideCurrentRepo(t *testing.T) { + repoDir := createGitSpecRepo(t) + chdirForTest(t, repoDir) + + absSpecPath := filepath.Join(repoDir, "openapi.yaml") + raw := "HEAD:" + absSpecPath + + source, err := resolveGitRefSource(raw, "HEAD", absSpecPath, summaryOpts{}) + require.NoError(t, err) + assert.Equal(t, raw, source.Display) + assert.NotEmpty(t, source.RootBytes) + expectedSpecPath, err := git.CanonicalizePath(absSpecPath) + require.NoError(t, err) + assert.Equal(t, expectedSpecPath, source.DocConfig.SpecFilePath) + assert.True(t, strings.HasSuffix(source.DocConfig.SpecFilePath, string(filepath.Separator)+"openapi.yaml")) +} + +func TestResolveGitRefSource_RequiresGitRepo(t *testing.T) { + chdirForTest(t, t.TempDir()) + + _, err := resolveGitRefSource("HEAD:go.mod", "HEAD", "go.mod", summaryOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires the current working directory to be inside a git repository") +} + +func TestResolveComparisonSource_URLSanitizesDisplay(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("openapi: 3.0.3\ninfo:\n title: test\n version: '1.0'\npaths: {}\n")) + })) + defer server.Close() + + source, err := resolveComparisonSource(server.URL+"/spec.yaml?token=secret#section", summaryOpts{}) + require.NoError(t, err) + + assert.Equal(t, server.URL+"/spec.yaml", source.Display) + assert.NotEmpty(t, source.RootBytes) + require.NotNil(t, source.DocConfig) + require.NotNil(t, source.DocConfig.BaseURL) + assert.Equal(t, server.URL+"/", source.DocConfig.BaseURL.String()) +} + +func TestResolveComparisonSource_HTTPStyleGitRefsAreNotURLs(t *testing.T) { + repoDir, specPath := createExplodedGitSpecRepo(t) + chdirForTest(t, repoDir) + + source, err := resolveComparisonSource("HEAD:apis/openapi.yaml", summaryOpts{}) + require.NoError(t, err) + assert.Equal(t, "HEAD:apis/openapi.yaml", source.Display) + assert.True(t, strings.HasSuffix(source.DocConfig.SpecFilePath, filepath.ToSlash(specPath)) || + strings.HasSuffix(filepath.ToSlash(source.DocConfig.SpecFilePath), "/apis/openapi.yaml")) +} + +func TestResolveComparisonSource_URLReturnsStatusErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "missing", http.StatusNotFound) + })) + defer server.Close() + + _, err := resolveComparisonSource(server.URL+"/spec.yaml", summaryOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 404 Not Found") +} + +func TestLoadLeftRightCommits_UsesSafeDisplayLabels(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + repoRoot := filepath.Clean(filepath.Join(wd, "..")) + chdirForTest(t, repoRoot) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("openapi: 3.0.3\ninfo:\n title: test\n version: '1.0'\npaths: {}\n")) + })) + defer server.Close() + + tests := []struct { + name string + left string + right string + originalLabel string + modifiedLabel string + }{ + { + name: "git ref and local file", + left: "HEAD:sample-specs/petstorev3.json", + right: "sample-specs/petstorev3.json", + originalLabel: "HEAD:sample-specs/petstorev3.json", + modifiedLabel: "sample-specs/petstorev3.json", + }, + { + name: "git ref and git ref", + left: "HEAD:sample-specs/petstorev3.json", + right: "HEAD:sample-specs/petstorev3.json", + originalLabel: "HEAD:sample-specs/petstorev3.json", + modifiedLabel: "HEAD:sample-specs/petstorev3.json", + }, + { + name: "url and local file", + left: server.URL + "/spec.yaml?token=secret#section", + right: "sample-specs/petstorev3.json", + originalLabel: server.URL + "/spec.yaml", + modifiedLabel: "sample-specs/petstorev3.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commits, err := loadLeftRightCommits(tt.left, tt.right, summaryOpts{}) + require.NoError(t, err) + require.Len(t, commits, 1) + assert.Equal(t, "Original: "+tt.originalLabel+", Modified: "+tt.modifiedLabel, commits[0].Message) + assert.NotNil(t, commits[0].Document) + assert.NotNil(t, commits[0].OldDocument) + }) + } +} + +func TestLoadLeftRightCommits_GitRefFromSubdirectoryUsesRepoRootSemantics(t *testing.T) { + repoDir := createGitSpecRepo(t) + subDir := filepath.Join(repoDir, "child") + require.NoError(t, os.Mkdir(subDir, 0o755)) + chdirForTest(t, subDir) + + commits, err := loadLeftRightCommits("HEAD:openapi.yaml", filepath.Join(repoDir, "openapi.yaml"), summaryOpts{}) + require.NoError(t, err) + require.Len(t, commits, 1) + assert.Equal(t, "Original: HEAD:openapi.yaml, Modified: "+filepath.Join(repoDir, "openapi.yaml"), commits[0].Message) +} + +func TestLoadCommitsFromArgs_GitRefUsesLeftRightDispatch(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + repoRoot := filepath.Clean(filepath.Join(wd, "..")) + chdirForTest(t, repoRoot) + + commits, err := loadCommitsFromArgs([]string{ + "HEAD:sample-specs/petstorev3.json", + "sample-specs/petstorev3.json", + }, summaryOpts{}, nil) + + require.NoError(t, err) + require.Len(t, commits, 1) + assert.Equal(t, "Original: HEAD:sample-specs/petstorev3.json, Modified: sample-specs/petstorev3.json", commits[0].Message) +} + +func TestLoadCommitsFromArgs_LocalColonPathOutsideGitRepoStaysFileComparison(t *testing.T) { + dir := t.TempDir() + chdirForTest(t, dir) + + leftPath := filepath.Join(dir, "v1:beta.yaml") + rightPath := filepath.Join(dir, "spec.yaml") + spec := []byte("openapi: 3.0.3\ninfo:\n title: test\n version: '1.0'\npaths: {}\n") + + require.NoError(t, os.WriteFile(leftPath, spec, 0o644)) + require.NoError(t, os.WriteFile(rightPath, spec, 0o644)) + + commits, err := loadCommitsFromArgs([]string{leftPath, rightPath}, summaryOpts{}, nil) + require.NoError(t, err) + require.Len(t, commits, 1) + assert.Equal(t, "Original: "+leftPath+", Modified: "+rightPath, commits[0].Message) +} + +func TestLoadCommitsFromArgs_RepoHistoryColonPathUsesHistoryDispatch(t *testing.T) { + repoDir := createGitSpecRepoForFile(t, "v1:beta.yaml") + chdirForTest(t, t.TempDir()) + + commits, err := loadCommitsFromArgs([]string{repoDir, "v1:beta.yaml"}, summaryOpts{limitTime: -1}, nil) + require.NoError(t, err) + require.NotEmpty(t, commits) + assert.Equal(t, repoDir, commits[0].RepoDirectory) + assert.Equal(t, "v1:beta.yaml", commits[0].FilePath) +} diff --git a/cmd/markdown_report.go b/cmd/markdown_report.go index b02f9f1..d524705 100644 --- a/cmd/markdown_report.go +++ b/cmd/markdown_report.go @@ -4,6 +4,7 @@ package cmd import ( + "errors" "fmt" "os" "strings" @@ -81,15 +82,15 @@ func isSyntheticLeftRightCommit(commit *model.Commit) bool { func generateMarkdownReport(commits []*model.Commit, breakingConfig *whatChangedModel.BreakingRulesConfig, includeDiff bool) ([]byte, error) { var sb strings.Builder successCount := 0 - errorCount := 0 + var renderErrors []error headerWritten := false includeCommitMetadata := true for i, commit := range commits { markdown, err := renderCommitMarkdown(commit, breakingConfig) if err != nil { - emitCommitWarning(commit.Hash, err) - errorCount++ + emitCommitWarning(commit, err) + renderErrors = append(renderErrors, wrapCommitError(commit, err)) continue } if markdown == "" { @@ -138,13 +139,13 @@ func generateMarkdownReport(commits []*model.Commit, breakingConfig *whatChanged sb.WriteString("---\n\n") } - if successCount == 0 && errorCount > 0 { - return nil, fmt.Errorf("all %d commits failed to render", errorCount) + if successCount == 0 && len(renderErrors) > 0 { + return nil, fmt.Errorf("all %d commits failed to render: %w", len(renderErrors), errors.Join(renderErrors...)) } - if errorCount > 0 { - fmt.Fprintf(os.Stderr, "warning: %d commits failed to render\n", errorCount) + if len(renderErrors) > 0 { + fmt.Fprintf(os.Stderr, "warning: %d commits failed to render\n", len(renderErrors)) } - if successCount == 0 && errorCount == 0 { + if successCount == 0 && len(renderErrors) == 0 { return nil, nil } return []byte(sb.String()), nil @@ -156,7 +157,7 @@ func GetMarkdownReportCommand() *cobra.Command { Use: "markdown-report", Short: "Generate a markdown report", Long: "Generate a detailed markdown report of API changes rendered as markdown", - Example: "openapi-changes markdown-report /path/to/git/repo path/to/file/in/repo/openapi.yaml", + Example: "openapi-changes markdown-report HEAD~1:openapi.yaml ./openapi.yaml", RunE: func(cmd *cobra.Command, args []string) error { input, err := prepareCommandRun(cmd, args, printMarkdownReportUsage) if err != nil { diff --git a/cmd/markdown_report_test.go b/cmd/markdown_report_test.go index 23f7078..ec9689a 100644 --- a/cmd/markdown_report_test.go +++ b/cmd/markdown_report_test.go @@ -19,7 +19,7 @@ func TestMarkdownReport_UnchangedLeftRight(t *testing.T) { commits, err := loadLeftRightCommits( "../sample-specs/petstorev3.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) @@ -56,12 +56,32 @@ paths: {}` assert.Contains(t, err.Error(), "all 1 commits failed to render") } +func TestMarkdownReport_LeftRightModelBuildErrorNamesFiles(t *testing.T) { + leftPath, rightPath := createBrokenReferenceSpecPair(t) + + commits, err := loadLeftRightCommits(leftPath, rightPath, summaryOpts{noColor: true}) + require.NoError(t, err) + + var report []byte + stderr := captureStderr(t, func() { + report, err = generateMarkdownReport(commits, nil, false) + }) + + require.Error(t, err) + assert.Nil(t, report) + assert.Contains(t, err.Error(), "all 1 commits failed to render") + assert.Contains(t, err.Error(), rightPath) + assert.Contains(t, err.Error(), "building modified model") + assert.Contains(t, stderr, leftPath) + assert.Contains(t, stderr, rightPath) +} + func TestMarkdownReport_HeadingStripping(t *testing.T) { opts := summaryOpts{noColor: true} commits, err := loadLeftRightCommits( "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) @@ -78,7 +98,7 @@ func TestMarkdownReport_SingleCommitLeftRight(t *testing.T) { commits, err := loadLeftRightCommits( "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) @@ -97,7 +117,7 @@ func TestMarkdownReport_UsesDeduplicatedCounts(t *testing.T) { commits, err := loadLeftRightCommits( "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) @@ -117,7 +137,7 @@ func TestMarkdownReport_UsesDeduplicatedObjectStats(t *testing.T) { commits, err := loadLeftRightCommits( "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) @@ -140,7 +160,7 @@ func TestMarkdownReport_LeftRightOmitsSyntheticCommitMetadata(t *testing.T) { commits, err := loadLeftRightCommits( "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) @@ -162,7 +182,7 @@ func TestMarkdownReport_PartialFailureReturnsPartialResults(t *testing.T) { validCommits, err := loadLeftRightCommits( "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) require.NotEmpty(t, validCommits) @@ -197,7 +217,7 @@ paths: {}` }) assert.NoError(t, reportErr) assert.NotNil(t, report, "should return partial report with successfully rendered commits") - assert.Contains(t, stderr, "warning: commit bad123: building right model") + assert.Contains(t, stderr, "warning: commit bad123: building modified model") assert.Contains(t, stderr, "warning: 1 commits failed to render") } @@ -206,7 +226,7 @@ func TestMarkdownReport_IncludeDiffFlag(t *testing.T) { commits, err := loadLeftRightCommits( "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) @@ -221,7 +241,7 @@ func TestMarkdownReport_IncludeDiffFlag(t *testing.T) { commits, err = loadLeftRightCommits( "../sample-specs/petstorev3-original.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) diff --git a/cmd/report.go b/cmd/report.go index 7a2e384..6d5be3c 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -10,7 +10,6 @@ import ( "net/url" "os" "path" - "strings" "time" whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" @@ -23,7 +22,7 @@ import ( func changerateCommit(commit *model.Commit, breakingConfig *whatChangedModel.BreakingRulesConfig) (*changeratorResult, error) { result, err := runChangerator(commit, breakingConfig) if err != nil { - return nil, fmt.Errorf("commit %s: %w", commit.Hash, err) + return nil, wrapCommitError(commit, err) } if result == nil { return nil, nil @@ -54,14 +53,11 @@ func changerateAndFlatten(commits []*model.Commit, breakingConfig *whatChangedMo } func runLeftRightReport(left, right string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) (*model.FlatReport, error) { - commits, err := loadLeftRightCommits(left, right, opts, breakingConfig) + commit, err := buildLeftRightCommitAndSources(left, right, opts) if err != nil { return nil, err } - if len(commits) == 0 { - return nil, nil - } - result, err := changerateCommit(commits[0], breakingConfig) + result, err := changerateCommit(commit, breakingConfig) if err != nil { return nil, err } @@ -69,8 +65,10 @@ func runLeftRightReport(left, right string, opts summaryOpts, breakingConfig *wh return nil, nil } defer result.Release() - flat := FlattenReportWithParameterNames(createReport(commits[0]), result.Changerator.ParameterNames) + flat := FlattenReportWithParameterNames(createReport(commit), result.Changerator.ParameterNames) flat.Commit = nil + flat.OriginalPath = sourceLabelForReport(left) + flat.ModifiedPath = sourceLabelForReport(right) return flat, nil } @@ -139,12 +137,16 @@ func GetReportCommand() *cobra.Command { Use: "report", Short: "Generate a machine readable report", Long: "Generate a JSON report for what has changed between commits/specs", - Example: "openapi-changes report /path/to/git/repo path/to/file/in/repo/openapi.yaml", + Example: "openapi-changes report HEAD~1:openapi.yaml ./openapi.yaml", RunE: func(cmd *cobra.Command, args []string) error { opts, configFlag, err := readCommonFlags(cmd) if err != nil { return err } + reproducible, err := cmd.Flags().GetBool("reproducible") + if err != nil { + return err + } if len(args) == 0 { maybePrintBanner(cmd, opts.palette) @@ -173,11 +175,16 @@ func GetReportCommand() *cobra.Command { if reportErr != nil { return reportErr } + if reproducible { + makeReportOutputReproducible(flat) + } return printReportJSON(flat) } - firstURL, _ := url.Parse(args[0]) - if firstURL == nil || !strings.HasPrefix(firstURL.Scheme, "http") { + if !isHTTPURL(args[0]) { + if _, _, ok := parseGitRef(args[0]); ok { + return printReportJSONOrNoChanges(args, opts, breakingConfig, reproducible) + } f, statErr := os.Stat(args[0]) if statErr == nil && f.IsDir() { flat, reportErr := runGitHistoryReport(args[0], args[1], opts, breakingConfig) @@ -188,21 +195,59 @@ func GetReportCommand() *cobra.Command { printNoChangesJSON() return nil } + if reproducible { + makeReportOutputReproducible(flat) + } return printReportJSON(flat) } + if _, _, ok := parseGitRef(args[1]); ok { + return printReportJSONOrNoChanges(args, opts, breakingConfig, reproducible) + } } - flat, reportErr := runLeftRightReport(args[0], args[1], opts, breakingConfig) - if reportErr != nil { - return reportErr - } - if flat == nil { - printNoChangesJSON() - return nil - } - return printReportJSON(flat) + return printReportJSONOrNoChanges(args, opts, breakingConfig, reproducible) }, } addTerminalThemeFlags(cmd) + cmd.Flags().Bool("reproducible", false, "Omit generated timestamps from report JSON") return cmd } + +func printReportJSONOrNoChanges(args []string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig, reproducible bool) error { + flat, reportErr := runLeftRightReport(args[0], args[1], opts, breakingConfig) + if reportErr != nil { + return reportErr + } + if flat == nil { + printNoChangesJSON() + return nil + } + if reproducible { + makeReportOutputReproducible(flat) + } + return printReportJSON(flat) +} + +func makeReportOutputReproducible(report any) { + switch typed := report.(type) { + case *model.FlatReport: + if typed != nil { + makeFlatReportReproducible(typed) + } + case *model.FlatHistoricalReport: + if typed == nil { + return + } + typed.DateGenerated = "" + for _, item := range typed.Reports { + makeFlatReportReproducible(item) + } + } +} + +func makeFlatReportReproducible(report *model.FlatReport) { + if report == nil { + return + } + report.DateGenerated = "" +} diff --git a/cmd/report_test.go b/cmd/report_test.go index 8c831a2..41d1252 100644 --- a/cmd/report_test.go +++ b/cmd/report_test.go @@ -5,6 +5,8 @@ package cmd import ( "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -18,34 +20,15 @@ import ( ) func TestRunLeftRightReport_PropagatesChangeratorErrors(t *testing.T) { - dir := t.TempDir() - - left := `swagger: "2.0" -info: - title: test - version: "1.0" -paths: {}` - right := `swagger: "2.0" -info: - title: test updated - version: "1.1" -paths: - /pets: - get: - responses: - "200": - description: ok` - - leftPath := filepath.Join(dir, "left.yaml") - rightPath := filepath.Join(dir, "right.yaml") - require.NoError(t, os.WriteFile(leftPath, []byte(left), 0644)) - require.NoError(t, os.WriteFile(rightPath, []byte(right), 0644)) + leftPath, rightPath := createBrokenReferenceSpecPair(t) report, err := runLeftRightReport(leftPath, rightPath, summaryOpts{}, nil) require.Error(t, err) assert.Nil(t, report) - assert.Contains(t, err.Error(), "building right model") + assert.Contains(t, err.Error(), leftPath) + assert.Contains(t, err.Error(), rightPath) + assert.Contains(t, err.Error(), "building modified model") } func TestRunGithubHistoryReport_PropagatesChangeratorErrors(t *testing.T) { @@ -90,11 +73,62 @@ func TestRunLeftRightReport_Success(t *testing.T) { assert.NotEmpty(t, report.Changes) assert.NotEmpty(t, report.DateGenerated) assert.Nil(t, report.Commit) + assert.Equal(t, "../sample-specs/petstorev3-original.json", report.OriginalPath) + assert.Equal(t, "../sample-specs/petstorev3.json", report.ModifiedPath) assert.Contains(t, report.Summary, "paths") assert.Equal(t, 30, report.Summary["paths"].Total) assert.Equal(t, 16, report.Summary["paths"].Breaking) } +func TestRunLeftRightReport_SanitizesURLSourceMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/left.yaml" { + _, _ = w.Write([]byte("openapi: 3.0.3\ninfo:\n title: Left\n version: '1.0'\npaths: {}\n")) + return + } + _, _ = w.Write([]byte("openapi: 3.0.3\ninfo:\n title: Right\n version: '1.1'\npaths:\n /pets:\n get:\n responses:\n \"200\":\n description: ok\n")) + })) + defer server.Close() + + leftURL := server.URL + "/left.yaml?token=left-secret#frag" + rightURL := server.URL + "/right.yaml?token=right-secret#frag" + + report, err := runLeftRightReport(leftURL, rightURL, summaryOpts{noColor: true}, nil) + + require.NoError(t, err) + require.NotNil(t, report) + assert.Equal(t, server.URL+"/left.yaml", report.OriginalPath) + assert.Equal(t, server.URL+"/right.yaml", report.ModifiedPath) + + encoded, err := json.Marshal(report) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "left-secret") + assert.NotContains(t, string(encoded), "right-secret") +} + +func TestRunLeftRightReport_SummaryIncludesDocumentLevelOpenAPIChanges(t *testing.T) { + dir := t.TempDir() + + left := "openapi: 3.0.0\ninfo:\n title: test\n version: 1.0.0\npaths: {}\n" + right := "openapi: 3.1.0\ninfo:\n title: test\n version: 2.0.0\npaths: {}\n" + + leftPath := filepath.Join(dir, "left.yaml") + rightPath := filepath.Join(dir, "right.yaml") + require.NoError(t, os.WriteFile(leftPath, []byte(left), 0o644)) + require.NoError(t, os.WriteFile(rightPath, []byte(right), 0o644)) + + report, err := runLeftRightReport(leftPath, rightPath, summaryOpts{noColor: true}, nil) + + require.NoError(t, err) + require.NotNil(t, report) + require.Contains(t, report.Summary, "openapi") + require.Contains(t, report.Summary, "info") + assert.Equal(t, 1, report.Summary["openapi"].Total) + assert.Equal(t, 1, report.Summary["openapi"].Breaking) + assert.Equal(t, 1, report.Summary["info"].Total) + assert.Equal(t, 0, report.Summary["info"].Breaking) +} + func TestRunLeftRightReport_OmitsCommitDetailsInJSON(t *testing.T) { report, err := runLeftRightReport( "../sample-specs/petstorev3-original.json", @@ -112,6 +146,70 @@ func TestRunLeftRightReport_OmitsCommitDetailsInJSON(t *testing.T) { assert.NotContains(t, string(encoded), "commitDetails") } +func TestReportCommand_ReproducibleOutputIsStableAcrossRuns(t *testing.T) { + args := []string{ + "--no-logo", + "--no-color", + "--reproducible", + "../sample-specs/petstorev3-original.json", + "../sample-specs/petstorev3.json", + } + + runOnce := func() string { + cmd := testRootCmd(GetReportCommand(), args...) + return captureStdout(t, func() { + require.NoError(t, cmd.Execute()) + }) + } + + first := runOnce() + second := runOnce() + + assert.Equal(t, stripRawPaths(t, first), stripRawPaths(t, second)) + assert.NotContains(t, first, `"dateGenerated"`) + assert.NotContains(t, first, `"commitDetails"`) + assert.Contains(t, first, `"rawPath"`) +} + +func TestRunLeftRightReport_GitRefExplodedSpecIncludesSiblingChanges(t *testing.T) { + repoDir, _ := createExplodedGitSpecRepo(t) + chdirForTest(t, repoDir) + + report, err := runLeftRightReport( + "HEAD~1:apis/openapi.yaml", + "HEAD:apis/openapi.yaml", + summaryOpts{noColor: true}, + nil, + ) + + require.NoError(t, err) + require.NotNil(t, report) + assert.NotEmpty(t, report.Changes) + + encoded, err := json.Marshal(report) + require.NoError(t, err) + content := string(encoded) + assert.Contains(t, content, `"property":"required"`) + assert.Contains(t, content, `"path":"$.paths['/pets'].get.responses['200'].content['application/json'].schema"`) + assert.Contains(t, content, `"document":"apis/components/pet.yaml"`) + assert.NotContains(t, content, ".openapi-changes-gitref") +} + +func TestRunLeftRightReport_LocalExplodedSpecIncludesSiblingChanges(t *testing.T) { + leftPath, rightPath := createExplodedLocalSpecPair(t) + + report, err := runLeftRightReport(leftPath, rightPath, summaryOpts{noColor: true}, nil) + + require.NoError(t, err) + require.NotNil(t, report) + assert.NotEmpty(t, report.Changes) + + encoded, err := json.Marshal(report) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"property":"required"`) + assert.Contains(t, string(encoded), `"path":"$.paths['/pets'].get.responses['200'].content['application/json'].schema"`) +} + func TestRunLeftRightReport_NormalizesParameterPaths(t *testing.T) { report, err := runLeftRightReport( "../sample-specs/petstorev3-original.json", @@ -146,3 +244,124 @@ func TestRunLeftRightReport_NormalizesParameterPaths(t *testing.T) { assert.Contains(t, content, `"property":"required"`) assert.Contains(t, content, `"type":"parameter"`) } + +func TestRunLeftRightReport_ComposedSchemaTitleRemovalDoesNotMislabelAsAnyOf(t *testing.T) { + leftPath, rightPath := createComposedSchemaTitleRemovalSpecPair(t, "allOf") + + report, err := runLeftRightReport(leftPath, rightPath, summaryOpts{noColor: true}, nil) + + require.NoError(t, err) + require.NotNil(t, report) + require.Len(t, report.Changes, 1) + + change := report.Changes[0] + assert.Equal(t, "title", change.Property) + assert.Equal(t, "schema", change.Type) + assert.Equal(t, "$.components.schemas['Contract']", change.Path) + + encoded, err := json.Marshal(report) + require.NoError(t, err) + assert.NotContains(t, string(encoded), `"property":"anyOf"`) +} + +func TestRunLeftRightReport_GitRefBasePathUsesRevisionScopedSiblingRefs(t *testing.T) { + repoDir, fileName := createMovedRefGitSpecRepo(t) + chdirForTest(t, repoDir) + + report, err := runLeftRightReport( + "HEAD~1:"+fileName, + "HEAD:"+fileName, + summaryOpts{base: repoDir, noColor: true, limitTime: -1}, + nil, + ) + + require.NoError(t, err) + require.NotNil(t, report) + assert.NotEmpty(t, report.Changes) + + encoded, err := json.Marshal(report) + require.NoError(t, err) + content := string(encoded) + assert.Contains(t, content, `"property":"$ref"`) + assert.Contains(t, content, `"path":"$.paths['/thing'].get.responses['200'].content['application/json'].schema"`) +} + +func TestRunGitHistoryReport_BasePathUsesRevisionScopedSiblingRefs(t *testing.T) { + repoDir, fileName := createMovedRefGitSpecRepo(t) + + report, err := runGitHistoryReport(repoDir, fileName, summaryOpts{base: repoDir, noColor: true, limitTime: -1}, nil) + + require.NoError(t, err) + require.NotNil(t, report) + require.Len(t, report.Reports, 1) + assert.NotEmpty(t, report.Reports[0].Changes) + + encoded, err := json.Marshal(report) + require.NoError(t, err) + content := string(encoded) + assert.Contains(t, content, `"property":"$ref"`) + assert.Contains(t, content, `"path":"$.paths['/thing'].get.responses['200'].content['application/json'].schema"`) +} + +func TestReportCommand_GitRefUsesLeftRightMode(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + repoRoot := filepath.Clean(filepath.Join(wd, "..")) + chdirForTest(t, repoRoot) + + cmd := testRootCmd(GetReportCommand(), + "HEAD:sample-specs/petstorev3-original.json", + "sample-specs/petstorev3.json", + ) + + output := captureStdout(t, func() { + require.NoError(t, cmd.Execute()) + }) + + assert.Contains(t, output, `"changes"`) + assert.Contains(t, output, `"originalPath": "HEAD:sample-specs/petstorev3-original.json"`) + assert.Contains(t, output, `"modifiedPath": "sample-specs/petstorev3.json"`) + assert.Contains(t, output, `"summary"`) + assert.NotContains(t, output, `"reports"`) +} + +func TestReportCommand_RepoHistoryColonPathUsesHistoryMode(t *testing.T) { + repoDir := createGitSpecRepoForFile(t, "v1:beta.yaml") + chdirForTest(t, t.TempDir()) + + cmd := testRootCmd(GetReportCommand(), repoDir, "v1:beta.yaml") + output := captureStdout(t, func() { + require.NoError(t, cmd.Execute()) + }) + + assert.Contains(t, output, `"gitRepoPath": "`+repoDir+`"`) + assert.Contains(t, output, `"gitFilePath": "v1:beta.yaml"`) + assert.Contains(t, output, `"reports"`) + assert.NotContains(t, output, `"originalPath"`) +} + +func stripRawPaths(t *testing.T, raw string) string { + t.Helper() + + var payload any + require.NoError(t, json.Unmarshal([]byte(raw), &payload)) + removeJSONKey(payload, "rawPath") + + bits, err := json.MarshalIndent(payload, "", " ") + require.NoError(t, err) + return string(bits) +} + +func removeJSONKey(value any, key string) { + switch typed := value.(type) { + case map[string]any: + delete(typed, key) + for _, child := range typed { + removeJSONKey(child, key) + } + case []any: + for _, child := range typed { + removeJSONKey(child, key) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 6e613d9..f8efa51 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "os" + "sort" "github.com/spf13/cobra" ) @@ -20,9 +21,9 @@ var ( Use: "openapi-changes", Short: "Detect and explore changes between OpenAPI / Swagger specifications.", Long: `openapi-changes can detect every change found in an OpenAPI specification. -it can compare between two files, or a single file, over time. + it can compare between two files, or a single file, over time. -All commands use the current doctor-based engine.`, + The comparison and reporting commands use the current doctor-based engine.`, RunE: func(cmd *cobra.Command, args []string) error { opts, _, err := readCommonFlags(cmd) if err != nil { @@ -32,7 +33,7 @@ All commands use the current doctor-based engine.`, fmt.Println("Current commands") fmt.Println() - for _, name := range []string{"console", "summary", "report", "markdown-report", "html-report"} { + for _, name := range visibleRootCommandNames(cmd) { fmt.Printf(" > %s\n", name) } fmt.Println() @@ -53,6 +54,18 @@ func Execute(version, commit, date string) { } } +func visibleRootCommandNames(root *cobra.Command) []string { + var names []string + for _, command := range root.Commands() { + if command == nil || command.Hidden || command.Name() == "help" { + continue + } + names = append(names, command.Name()) + } + sort.Strings(names) + return names +} + func init() { cobra.OnInitialize(initConfig) rootCmd.AddCommand(GetConsoleCommand()) @@ -60,6 +73,7 @@ func init() { rootCmd.AddCommand(GetMarkdownReportCommand()) rootCmd.AddCommand(GetReportCommand()) rootCmd.AddCommand(GetSummaryCommand()) + rootCmd.AddCommand(GetVersionCommand()) rootCmd.PersistentFlags().BoolP("top", "t", false, "Only show latest changes (last git revision against HEAD)") rootCmd.PersistentFlags().IntP("limit", "l", 5, "Limit history to number of revisions (default is 5)") rootCmd.PersistentFlags().BoolP("global-revisions", "R", false, "Consider all revisions in limit, not just the ones for the file") diff --git a/cmd/root_test.go b/cmd/root_test.go index 21a5937..1d2293c 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -21,6 +21,8 @@ func TestRootCommand_ZeroArgsHonorsNoLogo(t *testing.T) { }) assert.Contains(t, output, "Current commands") + assert.Contains(t, output, "completion") + assert.Contains(t, output, "version") assert.NotContains(t, output, "https://pb33f.io/openapi-changes/") assert.NotContains(t, output, "@@@@@@@") } diff --git a/cmd/summary.go b/cmd/summary.go index 832b892..8b9c77e 100644 --- a/cmd/summary.go +++ b/cmd/summary.go @@ -301,8 +301,8 @@ func renderSummaryWithTheme( result, err := runChangerator(commit, breakingConfig) if err != nil { - emitCommitWarning(commit.Hash, err) - renderErrors = append(renderErrors, fmt.Errorf("commit %s: %w", commit.Hash, err)) + emitCommitWarning(commit, err) + renderErrors = append(renderErrors, wrapCommitError(commit, err)) continue } if result == nil { @@ -466,7 +466,7 @@ func GetSummaryCommand() *cobra.Command { Use: "summary", Short: "See a summary of changes", Long: "print a summary of what changed using the doctor changerator engine with tree visualization", - Example: "openapi-changes summary /path/to/git/repo path/to/file/in/repo/openapi.yaml", + Example: "openapi-changes summary HEAD~1:openapi.yaml ./openapi.yaml", RunE: func(cmd *cobra.Command, args []string) error { input, err := prepareCommandRun(cmd, args, printSummaryUsage) if err != nil { diff --git a/cmd/summary_test.go b/cmd/summary_test.go index 3399775..afb5631 100644 --- a/cmd/summary_test.go +++ b/cmd/summary_test.go @@ -183,7 +183,7 @@ func TestRenderSummary_ReturnsErrorWhenAllCommitsFailToRender(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "all 1 commits failed to render") - assert.Contains(t, stderr, "warning: commit abc123: building right model") + assert.Contains(t, stderr, "warning: commit abc123: building modified model") assert.Empty(t, output) assert.False(t, hasBreaking) assert.False(t, hasChanges) @@ -221,7 +221,7 @@ paths: {} require.NoError(t, err) assert.Contains(t, output, "Date:") assert.NotContains(t, output, "Error:") - assert.Contains(t, stderr, "warning: commit abc123: building right model") + assert.Contains(t, stderr, "warning: commit abc123: building modified model") assert.Contains(t, stderr, "warning: 1 commits failed to render") assert.False(t, hasBreaking) assert.True(t, hasChanges) @@ -233,22 +233,48 @@ func TestLoadLeftRightCommits_IdenticalSpecsPreserveComparableRevision(t *testin commits, err := loadLeftRightCommits( "../sample-specs/petstorev3.json", "../sample-specs/petstorev3.json", - opts, nil, + opts, ) require.NoError(t, err) - require.Len(t, commits, 2) + require.Len(t, commits, 1) assert.NotNil(t, commits[0].Document) assert.NotNil(t, commits[0].OldDocument) assert.Nil(t, commits[0].Changes) } +func TestRenderSummary_LeftRightModelBuildErrorNamesModifiedFile(t *testing.T) { + leftPath, rightPath := createBrokenReferenceSpecPair(t) + + commits, err := loadLeftRightCommits(leftPath, rightPath, summaryOpts{noColor: true}) + require.NoError(t, err) + require.Len(t, commits, 1) + + var renderErr error + stderr := captureStderr(t, func() { + _, _, _, renderErr = renderSummary( + commits, + nil, + false, + true, + false, + summaryStyles{}, + ) + }) + + require.Error(t, renderErr) + assert.Contains(t, renderErr.Error(), "all 1 commits failed to render") + assert.Contains(t, renderErr.Error(), rightPath) + assert.Contains(t, renderErr.Error(), "building modified model") + assert.Contains(t, stderr, leftPath) + assert.Contains(t, stderr, rightPath) +} + func TestRenderSummary_DirectComparisonNoChangesUsesGenericMessage(t *testing.T) { commits, err := loadLeftRightCommits( "../sample-specs/petstorev3.json", "../sample-specs/petstorev3.json", summaryOpts{noColor: true}, - nil, ) require.NoError(t, err) @@ -352,14 +378,7 @@ paths: {} assert.True(t, hasChanges) } -func TestLoadLeftRightCommits_DownloadedFilesAreCleanedUp(t *testing.T) { - originalHTTPGet := httpGet - originalBuildChangelog := buildChangelog - t.Cleanup(func() { - httpGet = originalHTTPGet - buildChangelog = originalBuildChangelog - }) - +func TestLoadLeftRightCommits_DownloadedFilesSanitizeURLLabels(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`openapi: 3.0.3 info: @@ -370,22 +389,114 @@ paths: {} })) defer server.Close() - var originalPath string - var modifiedPath string - buildChangelog = func(commits []*model.Commit, progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, - opts git.HistoryOptions, breakingConfig *whatChangedModel.BreakingRulesConfig, - ) ([]*model.Commit, []error) { - originalPath = strings.TrimPrefix(commits[1].Message, "Original file: ") - modifiedPath = strings.TrimPrefix(commits[0].Message, "Original: "+originalPath+", Modified: ") - return commits, nil - } + leftURL := server.URL + "/left.yaml?token=left-secret#frag" + rightURL := server.URL + "/right.yaml?token=right-secret#frag" + + commits, err := loadLeftRightCommits(leftURL, rightURL, summaryOpts{}) + + require.NoError(t, err) + require.Len(t, commits, 1) + assert.Equal(t, + "Original: "+server.URL+"/left.yaml, Modified: "+server.URL+"/right.yaml", + commits[0].Message, + ) +} + +func TestRenderSummary_GitRefExplodedSpecDetectsSiblingChanges(t *testing.T) { + repoDir, _ := createExplodedGitSpecRepo(t) + chdirForTest(t, repoDir) + + commits, err := loadLeftRightCommits( + "HEAD~1:apis/openapi.yaml", + "HEAD:apis/openapi.yaml", + summaryOpts{noColor: true}, + ) + require.NoError(t, err) + require.Len(t, commits, 1) + + output, hasBreaking, hasChanges, err := renderSummary( + commits, + nil, + false, + true, + false, + summaryStyles{}, + ) + require.NoError(t, err) + assert.True(t, hasChanges) + assert.True(t, hasBreaking) + assert.NotContains(t, output, "No changes found between specifications") + assert.Contains(t, output, "paths") + assert.Contains(t, output, "name") + assert.Contains(t, output, "Breaking Highlights") +} + +func TestRenderSummary_ComposedSchemaTitleRemovalDoesNotMislabelAsAnyOf(t *testing.T) { + leftPath, rightPath := createComposedSchemaTitleRemovalSpecPair(t, "allOf") + + commits, err := loadLeftRightCommits(leftPath, rightPath, summaryOpts{noColor: true}) + require.NoError(t, err) + require.Len(t, commits, 1) + + output, hasBreaking, hasChanges, err := renderSummary( + commits, + nil, + false, + true, + false, + summaryStyles{}, + ) + + require.NoError(t, err) + assert.True(t, hasChanges) + assert.False(t, hasBreaking) + assert.Contains(t, output, "title") + assert.NotContains(t, output, "anyOf") +} - commits, err := loadLeftRightCommits(server.URL+"/left.yaml", server.URL+"/right.yaml", summaryOpts{}, nil) +func TestRenderSummary_RendersRemovedRequiredMemberName(t *testing.T) { + commit := mustMakeDoctorOnlyCommitFromSpecs(t, "required-label", `openapi: 3.0.3 +info: + title: Test API + version: "1.0" +paths: {} +components: + schemas: + ReminderRequest: + type: object + required: + - result + properties: + result: + type: string +`, `openapi: 3.0.3 +info: + title: Test API + version: "1.0" +paths: {} +components: + schemas: + ReminderRequest: + type: object + properties: + result: + type: string +`) + + output, hasBreaking, hasChanges, err := renderSummary( + []*model.Commit{commit}, + nil, + true, + true, + true, + summaryStyles{}, + ) require.NoError(t, err) - require.Len(t, commits, 2) - assert.NoFileExists(t, originalPath) - assert.NoFileExists(t, modifiedPath) + assert.True(t, hasChanges) + assert.True(t, hasBreaking) + assert.Contains(t, output, "Removed ReminderRequest required/result") + assert.Contains(t, output, "required/result (") } func TestLoadLeftRightCommits_ReturnsHTTPStatusErrors(t *testing.T) { @@ -394,7 +505,7 @@ func TestLoadLeftRightCommits_ReturnsHTTPStatusErrors(t *testing.T) { })) defer server.Close() - commits, err := loadLeftRightCommits(server.URL+"/left.yaml", "../sample-specs/petstorev3.json", summaryOpts{}, nil) + commits, err := loadLeftRightCommits(server.URL+"/left.yaml", "../sample-specs/petstorev3.json", summaryOpts{}) require.Error(t, err) assert.Nil(t, commits) diff --git a/cmd/test_helpers_test.go b/cmd/test_helpers_test.go index 0452afc..77359b3 100644 --- a/cmd/test_helpers_test.go +++ b/cmd/test_helpers_test.go @@ -4,11 +4,16 @@ package cmd import ( + "fmt" + "os" + "os/exec" + "path/filepath" "testing" "time" "github.com/pb33f/libopenapi" whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" + "github.com/pb33f/openapi-changes/internal/testutil" "github.com/pb33f/openapi-changes/model" "github.com/stretchr/testify/require" ) @@ -37,3 +42,279 @@ paths: {}` Changes: &whatChangedModel.DocumentChanges{}, } } + +func chdirForTest(t *testing.T, dir string) { + t.Helper() + + oldWD, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWD)) + }) +} + +func createGitSpecRepo(t *testing.T) string { + t.Helper() + + repoDir := t.TempDir() + runGitInDir(t, repoDir, "init") + runGitInDir(t, repoDir, "config", "user.name", "Test User") + runGitInDir(t, repoDir, "config", "user.email", "test@example.com") + writeGitHistoryFile(t, repoDir, "openapi.yaml", + "openapi: 3.0.3\ninfo:\n title: first\n version: '1.0'\npaths: {}\n", + "openapi: 3.0.3\ninfo:\n title: second\n version: '1.1'\npaths:\n /pets:\n get:\n responses:\n \"200\":\n description: ok\n", + ) + + return repoDir +} + +func createGitSpecRepoForFile(t *testing.T, fileName string) string { + t.Helper() + + repoDir := t.TempDir() + runGitInDir(t, repoDir, "init") + runGitInDir(t, repoDir, "config", "user.name", "Test User") + runGitInDir(t, repoDir, "config", "user.email", "test@example.com") + writeGitHistoryFile(t, repoDir, fileName, + "openapi: 3.0.3\ninfo:\n title: first\n version: '1.0'\npaths: {}\n", + "openapi: 3.0.3\ninfo:\n title: second\n version: '1.1'\npaths:\n /pets:\n get:\n responses:\n \"200\":\n description: ok\n", + ) + specPath := filepath.Join(repoDir, fileName) + third := "openapi: 3.0.3\ninfo:\n title: third\n version: '1.2'\npaths:\n /pets:\n get:\n responses:\n \"200\":\n description: updated ok\n" + require.NoError(t, os.WriteFile(specPath, []byte(third), 0o644)) + runGitInDir(t, repoDir, "add", fileName) + runGitInDir(t, repoDir, "commit", "-m", "third") + + return repoDir +} + +func writeGitHistoryFile(t *testing.T, repoDir, fileName, first, second string) { + t.Helper() + + specPath := filepath.Join(repoDir, fileName) + require.NoError(t, os.MkdirAll(filepath.Dir(specPath), 0o755)) + require.NoError(t, os.WriteFile(specPath, []byte(first), 0o644)) + runGitInDir(t, repoDir, "add", fileName) + runGitInDir(t, repoDir, "commit", "-m", "first") + + require.NoError(t, os.WriteFile(specPath, []byte(second), 0o644)) + runGitInDir(t, repoDir, "add", fileName) + runGitInDir(t, repoDir, "commit", "-m", "second") +} + +func createExplodedGitSpecRepo(t *testing.T) (string, string) { + t.Helper() + + repoDir := t.TempDir() + runGitInDir(t, repoDir, "init") + runGitInDir(t, repoDir, "config", "user.name", "Test User") + runGitInDir(t, repoDir, "config", "user.email", "test@example.com") + + specDir := filepath.Join(repoDir, "apis") + componentDir := filepath.Join(specDir, "components") + require.NoError(t, os.MkdirAll(componentDir, 0o755)) + + rootPath := filepath.Join(specDir, "openapi.yaml") + componentPath := filepath.Join(componentDir, "pet.yaml") + + root := `openapi: 3.0.3 +info: + title: Exploded + version: "1.0" +paths: + /pets: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "./components/pet.yaml#/components/schemas/Pet" +` + firstComponent := `components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: integer +` + secondComponent := `components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string +` + + require.NoError(t, os.WriteFile(rootPath, []byte(root), 0o644)) + require.NoError(t, os.WriteFile(componentPath, []byte(firstComponent), 0o644)) + runGitInDir(t, repoDir, "add", "apis/openapi.yaml", "apis/components/pet.yaml") + runGitInDir(t, repoDir, "commit", "-m", "first") + + require.NoError(t, os.WriteFile(componentPath, []byte(secondComponent), 0o644)) + runGitInDir(t, repoDir, "add", "apis/components/pet.yaml") + runGitInDir(t, repoDir, "commit", "-m", "second") + + return repoDir, filepath.Join(specDir, "openapi.yaml") +} + +func createExplodedLocalSpecPair(t *testing.T) (string, string) { + t.Helper() + + root := `openapi: 3.0.3 +info: + title: Exploded + version: "1.0" +paths: + /pets: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "./components/pet.yaml#/components/schemas/Pet" +` + leftComponent := `components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: integer +` + rightComponent := `components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string +` + + createTree := func(baseDir, component string) string { + specDir := filepath.Join(baseDir, "apis") + componentDir := filepath.Join(specDir, "components") + require.NoError(t, os.MkdirAll(componentDir, 0o755)) + rootPath := filepath.Join(specDir, "openapi.yaml") + require.NoError(t, os.WriteFile(rootPath, []byte(root), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(componentDir, "pet.yaml"), []byte(component), 0o644)) + return rootPath + } + + baseDir := t.TempDir() + leftPath := createTree(filepath.Join(baseDir, "left"), leftComponent) + rightPath := createTree(filepath.Join(baseDir, "right"), rightComponent) + return leftPath, rightPath +} + +func createComposedSchemaTitleRemovalSpecPair(t *testing.T, keyword string) (string, string) { + t.Helper() + + left := fmt.Sprintf(`openapi: 3.1.0 +info: + title: Contracts API + version: "1.0" +paths: {} +components: + schemas: + Contract: + title: Contract + %s: + - type: object + properties: + id: + type: string +`, keyword) + + right := fmt.Sprintf(`openapi: 3.1.0 +info: + title: Contracts API + version: "1.0" +paths: {} +components: + schemas: + Contract: + %s: + - type: object + properties: + id: + type: string +`, keyword) + + baseDir := t.TempDir() + leftPath := filepath.Join(baseDir, "left.yaml") + rightPath := filepath.Join(baseDir, "right.yaml") + require.NoError(t, os.WriteFile(leftPath, []byte(left), 0o644)) + require.NoError(t, os.WriteFile(rightPath, []byte(right), 0o644)) + return leftPath, rightPath +} + +func createBrokenReferenceSpecPair(t *testing.T) (string, string) { + t.Helper() + + left := `openapi: 3.0.3 +info: + title: Valid + version: "1.0" +paths: {} +` + + right := `openapi: 3.0.3 +info: + title: Broken + version: "1.0" +paths: + /pets: + get: + responses: + "403": + description: forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/403-Forbidden' +components: + schemas: {} +` + + baseDir := t.TempDir() + leftPath := filepath.Join(baseDir, "left.yaml") + rightPath := filepath.Join(baseDir, "right.yaml") + require.NoError(t, os.WriteFile(leftPath, []byte(left), 0o644)) + require.NoError(t, os.WriteFile(rightPath, []byte(right), 0o644)) + return leftPath, rightPath +} + +func createMovedRefGitSpecRepo(t *testing.T) (string, string) { + t.Helper() + return testutil.CreateMovedRefGitSpecRepo(t) +} + +func runGitInDir(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(out)) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..04ca18d --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,27 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// GetVersionCommand returns the cobra command for printing the raw build version. +func GetVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the openapi-changes version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + version := Version + if version == "" { + version = "latest" + } + _, err := fmt.Fprintln(cmd.OutOrStdout(), version) + return err + }, + } +} diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 0000000..b0dfa44 --- /dev/null +++ b/cmd/version_test.go @@ -0,0 +1,26 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionCommand_PrintsVersionOnly(t *testing.T) { + originalVersion := Version + Version = "v1.2.3-test" + t.Cleanup(func() { + Version = originalVersion + }) + + cmd := testRootCmd(GetVersionCommand()) + output := captureStdout(t, func() { + require.NoError(t, cmd.Execute()) + }) + + assert.Equal(t, "v1.2.3-test\n", output) +} diff --git a/git/github.go b/git/github.go index d4bd5b1..bb80ffc 100644 --- a/git/github.go +++ b/git/github.go @@ -26,7 +26,7 @@ var newGitHubService = func() githubHistoryService { return doctorgithub.NewGitHubService() } -func convertGitHubRevisionsIntoModel(revisions []*doctorgithub.FileRevision, +func convertGitHubRevisionsIntoModel(revisions []*doctorgithub.FileRevision, filePath string, progressChan chan *model.ProgressUpdate, progressErrorChan chan model.ProgressError, opts HistoryOptions, breakingConfig *whatChangedModel.BreakingRulesConfig, ) ([]*model.Commit, []error) { @@ -50,6 +50,7 @@ func convertGitHubRevisionsIntoModel(revisions []*doctorgithub.FileRevision, AuthorEmail: revision.Commit.Author.Email, CommitDate: revision.Commit.Author.Date, Data: revision.FileBytes, + FilePath: filePath, } model.SendProgressUpdate("converting commits", fmt.Sprintf("Converted commit %s into data model", commit.Hash), false, progressChan) @@ -124,7 +125,7 @@ func ProcessGithubRepo(username, repo, filePath string, model.SendProgressUpdate("git", fmt.Sprintf("fetched %d github revisions", len(revisions)), true, progressChan) - commitHistory, errs := convertGitHubRevisionsIntoModel(revisions, progressChan, errorChan, opts, breakingConfig) + commitHistory, errs := convertGitHubRevisionsIntoModel(revisions, filePath, progressChan, errorChan, opts, breakingConfig) if errs != nil { for _, err := range errs { model.SendProgressError("git", err.Error(), errorChan) diff --git a/git/github_test.go b/git/github_test.go index 36b55dd..4064f89 100644 --- a/git/github_test.go +++ b/git/github_test.go @@ -144,11 +144,12 @@ func TestConvertGitHubRevisionsIntoModel_BuildsCommitHistory(t *testing.T) { makeDoctorRevision("bbb222", "old", time.Now().Add(-time.Hour), older), } - result, errs := convertGitHubRevisionsIntoModel(revisions, progressChan, errorChan, HistoryOptions{}, nil) + result, errs := convertGitHubRevisionsIntoModel(revisions, "spec.yaml", progressChan, errorChan, HistoryOptions{}, nil) require.Nil(t, errs) require.NotEmpty(t, result) assert.Equal(t, "aaa111", result[0].Hash) + assert.Equal(t, "spec.yaml", result[0].FilePath) assert.NotNil(t, result[0].Document) } @@ -160,7 +161,7 @@ func TestConvertGitHubRevisionsIntoModel_ReturnsBuildErrors(t *testing.T) { makeDoctorRevision("bbb222", "old", time.Now().Add(-time.Hour), []byte("openapi: 3.0.0\ninfo:\n title: ok\n version: 1.0.0\npaths: {}\n")), } - result, errs := convertGitHubRevisionsIntoModel(revisions, progressChan, errorChan, HistoryOptions{}, &whatChangedModel.BreakingRulesConfig{}) + result, errs := convertGitHubRevisionsIntoModel(revisions, "spec.yaml", progressChan, errorChan, HistoryOptions{}, &whatChangedModel.BreakingRulesConfig{}) assert.NotNil(t, result) require.NotEmpty(t, errs) diff --git a/git/read_local.go b/git/read_local.go index 053b73d..3023071 100644 --- a/git/read_local.go +++ b/git/read_local.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "log/slog" - "net/url" "os/exec" "path" "strconv" @@ -203,17 +202,13 @@ func buildCommitChangelog(commitHistory []*model.Commit, docConfig.IgnoreArrayCircularReferences = true docConfig.IgnorePolymorphicCircularReferences = true - if opts.Base != "" { - // check if this is a URL or not - u, e := url.Parse(opts.Base) - if e == nil && u.Scheme != "" && u.Host != "" { - docConfig.BaseURL = u - docConfig.BasePath = "" - docConfig.AllowRemoteReferences = true - } else { - docConfig.AllowFileReferences = true - docConfig.BasePath = opts.Base - } + basePathOverride, baseURLOverride, err := ResolveBaseOverride(opts.Base) + if err != nil { + return nil, []error{err} + } + if baseURLOverride != nil { + docConfig.BaseURL = baseURLOverride + docConfig.AllowRemoteReferences = true } // if this is set to true, we'll allow remote references @@ -231,27 +226,49 @@ func buildCommitChangelog(commitHistory []*model.Commit, }) docConfig.Logger = logger + var revisionContext *RevisionDocumentContext + if len(commitHistory) > 0 { + revisionContext, err = BuildRevisionDocumentContext( + commitHistory[0].RepoDirectory, + commitHistory[0].FilePath, + basePathOverride, + baseURLOverride, + ) + if err != nil { + return nil, []error{err} + } + } for c := len(commitHistory) - 1; c > -1; c-- { var oldBits, newBits []byte + var oldRevision, newRevision string if len(commitHistory) == c+1 { newBits = commitHistory[c].Data + newRevision = commitHistory[c].Hash // Obtain data from the previous commit and fail gracefully, if git // errors. This might happen when the file does not exist in the git // history. - oldBits, _ = readFile(commitHistory[c].RepoDirectory, fmt.Sprintf("%s~1", commitHistory[c].Hash), commitHistory[c].FilePath) + oldRevision = fmt.Sprintf("%s~1", commitHistory[c].Hash) + oldBits, _ = readFile(commitHistory[c].RepoDirectory, oldRevision, commitHistory[c].FilePath) } else { oldBits = commitHistory[c+1].Data + oldRevision = commitHistory[c+1].Hash commitHistory[c].OldData = oldBits newBits = commitHistory[c].Data + newRevision = commitHistory[c].Hash } var oldDoc, newDoc libopenapi.Document - var err error if len(oldBits) > 0 && len(newBits) > 0 { - oldDoc, err = libopenapi.NewDocumentWithConfiguration(oldBits, docConfig) + oldDocConfig, configErr := BuildRevisionDocumentConfiguration(revisionContext, oldRevision, docConfig) + if configErr != nil { + model.SendFatalError("building models", fmt.Sprintf("unable to configure original document '%s': %s", commitHistory[c].FilePath, configErr.Error()), errorChan) + changeErrors = append(changeErrors, configErr) + return nil, changeErrors + } + oldDoc, err = libopenapi.NewDocumentWithConfiguration(oldBits, oldDocConfig) if err != nil { model.SendFatalError("building models", fmt.Sprintf("unable to parse original document '%s': %s", commitHistory[c].FilePath, err.Error()), errorChan) @@ -261,7 +278,13 @@ func buildCommitChangelog(commitHistory []*model.Commit, model.SendProgressUpdate("building models", fmt.Sprintf("Building original model for commit %s", commitHistory[c].Hash[0:6]), false, progressChan) } - newDoc, err = libopenapi.NewDocumentWithConfiguration(newBits, docConfig) + newDocConfig, configErr := BuildRevisionDocumentConfiguration(revisionContext, newRevision, docConfig) + if configErr != nil { + model.SendFatalError("building models", fmt.Sprintf("unable to configure modified document '%s': %s", commitHistory[c].FilePath, configErr.Error()), errorChan) + changeErrors = append(changeErrors, configErr) + return nil, changeErrors + } + newDoc, err = libopenapi.NewDocumentWithConfiguration(newBits, newDocConfig) if err != nil { model.SendProgressError("building models", fmt.Sprintf("unable to parse modified document '%s': %s", commitHistory[c].FilePath, err.Error()), errorChan) changeErrors = append(changeErrors, err) @@ -286,7 +309,13 @@ func buildCommitChangelog(commitHistory []*model.Commit, fmt.Sprintf("Commit %s is the first version of '%s' — no prior version to compare against, skipping", commitHistory[c].Hash, commitHistory[c].FilePath), progressChan) } - newDoc, err = libopenapi.NewDocumentWithConfiguration(newBits, docConfig) + newDocConfig, configErr := BuildRevisionDocumentConfiguration(revisionContext, newRevision, docConfig) + if configErr != nil { + model.SendFatalError("building models", fmt.Sprintf("unable to configure modified document '%s': %s", commitHistory[c].FilePath, configErr.Error()), errorChan) + changeErrors = append(changeErrors, configErr) + return nil, changeErrors + } + newDoc, err = libopenapi.NewDocumentWithConfiguration(newBits, newDocConfig) if err != nil { model.SendFatalError("building models", fmt.Sprintf("unable to create OpenAPI modified document: %s", err.Error()), errorChan) changeErrors = append(changeErrors, err) @@ -299,6 +328,9 @@ func buildCommitChangelog(commitHistory []*model.Commit, if oldDoc != nil { commitHistory[c].OldDocument = oldDoc } + if revisionContext != nil && revisionContext.DocumentRewriter != nil { + commitHistory[c].DocumentRewriters = []model.DocumentPathRewriter{revisionContext.DocumentRewriter} + } // Preserve the oldest entry as a sentinel when there is no prior version. // The legacy commands keep only revisions with libopenapi changes, while // the new doctor-based commands keep every revision with comparable docs. @@ -333,3 +365,12 @@ func readFile(repoDir, hash, filePath string) ([]byte, error) { return ou.Bytes(), nil } + +// ReadFileAtRevision reads a file from a git repository at the given revision. +func ReadFileAtRevision(repoDir, revision, filePath string) ([]byte, error) { + data, err := readFile(repoDir, revision, filePath) + if err != nil { + return nil, fmt.Errorf("cannot read '%s' at revision '%s': %w", filePath, revision, err) + } + return data, nil +} diff --git a/git/read_local_test.go b/git/read_local_test.go index 93f9ac8..ee7d258 100644 --- a/git/read_local_test.go +++ b/git/read_local_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/pb33f/openapi-changes/internal/testutil" "github.com/pb33f/openapi-changes/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -107,6 +108,26 @@ func TestReadFile(t *testing.T) { assert.NotEmpty(t, contentRaw) } +func TestReadFileAtRevision(t *testing.T) { + contentRaw, err := ReadFileAtRevision("../", "HEAD", "go.mod") + require.NoError(t, err) + assert.NotEmpty(t, contentRaw) +} + +func TestReadFileAtRevision_BadRevision(t *testing.T) { + contentRaw, err := ReadFileAtRevision("../", "not-a-real-revision", "go.mod") + require.Error(t, err) + assert.Nil(t, contentRaw) + assert.Contains(t, err.Error(), "cannot read 'go.mod' at revision 'not-a-real-revision'") +} + +func TestReadFileAtRevision_BadFile(t *testing.T) { + contentRaw, err := ReadFileAtRevision("../", "HEAD", "missing-file.yaml") + require.Error(t, err) + assert.Nil(t, contentRaw) + assert.Contains(t, err.Error(), "cannot read 'missing-file.yaml' at revision 'HEAD'") +} + func TestBuildChangelog_IdenticalLeftRightPreservesSentinelCommit(t *testing.T) { specBytes := mustReadTestFile(t, "../sample-specs/petstorev3.json") progressChan := make(chan *model.ProgressUpdate, 32) @@ -166,6 +187,26 @@ func TestBuildChangelog_IdenticalLeftRightPreservesComparableRevision(t *testing assert.Nil(t, cleaned[0].Changes) } +func TestPopulateHistory_UsesRevisionScopedSiblingRefs(t *testing.T) { + repoDir, fileName := testutil.CreateMovedRefGitSpecRepo(t) + progressChan := make(chan *model.ProgressUpdate, 32) + errorChan := make(chan model.ProgressError, 32) + + history, errs := ExtractHistoryFromFile(repoDir, fileName, progressChan, errorChan, HistoryOptions{Limit: 0, LimitTime: -1}) + require.Empty(t, errs) + require.Len(t, history, 2) + + cleaned, errs := PopulateHistory(history, progressChan, errorChan, HistoryOptions{ + Base: repoDir, + KeepComparable: true, + }, nil) + require.Empty(t, errs) + require.Len(t, cleaned, 2) + require.NotNil(t, cleaned[0].Document) + require.NotNil(t, cleaned[0].OldDocument) + require.NotEmpty(t, cleaned[0].DocumentRewriters) +} + func runGit(t *testing.T, dir string, args ...string) { t.Helper() diff --git a/git/revision_documents.go b/git/revision_documents.go new file mode 100644 index 0000000..3b49503 --- /dev/null +++ b/git/revision_documents.go @@ -0,0 +1,221 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: Apache-2.0 + +package git + +import ( + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/openapi-changes/model" +) + +type RevisionDocumentContext struct { + RepoRoot string + RepoFilePath string + SpecFilePath string + BasePath string + BaseURL *url.URL + DocumentRewriter model.DocumentPathRewriter +} + +func ResolveBaseOverride(base string) (string, *url.URL, error) { + if base == "" { + return "", nil, nil + } + if parsed, err := url.Parse(base); err == nil && parsed != nil && parsed.Scheme != "" && parsed.Host != "" { + parsed.Path = path.Dir(parsed.Path) + parsed.RawQuery = "" + parsed.Fragment = "" + return "", parsed, nil + } + absBase, err := CanonicalizePath(base) + if err != nil { + return "", nil, fmt.Errorf("cannot resolve base path '%s': %w", base, err) + } + return absBase, nil, nil +} + +func NewRepoRelativeDocumentPathRewriter(repoRoot, basePath string) model.DocumentPathRewriter { + canonicalRepoRoot, err := CanonicalizePath(repoRoot) + if err != nil { + return nil + } + canonicalBasePath, err := CanonicalizePath(basePath) + if err != nil { + return nil + } + + baseRelPath, err := filepath.Rel(canonicalRepoRoot, canonicalBasePath) + if err != nil { + return nil + } + if baseRelPath == ".." || strings.HasPrefix(baseRelPath, ".."+string(filepath.Separator)) { + return nil + } + cleanBasePath := filepath.Clean(canonicalBasePath) + if baseRelPath == "." { + baseRelPath = "" + } + + return func(raw string) string { + cleaned := filepath.Clean(filepath.FromSlash(raw)) + relPath, err := filepath.Rel(cleanBasePath, cleaned) + if err != nil { + return raw + } + if relPath == "." || relPath == "" { + return raw + } + if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + return raw + } + if baseRelPath != "" { + relPath = filepath.Join(baseRelPath, relPath) + } + return filepath.ToSlash(filepath.Clean(relPath)) + } +} + +func BuildRevisionDocumentContext(repoDir, filePath, basePathOverride string, baseURLOverride *url.URL) (*RevisionDocumentContext, error) { + repoRoot, err := GetTopLevel(repoDir) + if err != nil { + return nil, fmt.Errorf("cannot determine repository root for '%s': %w", repoDir, err) + } + repoRoot, err = CanonicalizePath(repoRoot) + if err != nil { + return nil, fmt.Errorf("cannot canonicalize repository root '%s': %w", repoRoot, err) + } + + repoFilePath, err := normalizeRevisionRepoPath(repoDir, repoRoot, filePath) + if err != nil { + return nil, err + } + + basePath := repoRoot + if basePathOverride != "" { + basePath = basePathOverride + } + + specPath := filepath.Join(repoRoot, filepath.FromSlash(repoFilePath)) + relativeSpecPath, err := filepath.Rel(basePath, specPath) + if err != nil { + return nil, fmt.Errorf("cannot relate spec path '%s' to base path '%s': %w", specPath, basePath, err) + } + if relativeSpecPath == ".." || filepath.IsAbs(relativeSpecPath) || strings.HasPrefix(relativeSpecPath, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("revision path '%s' is not contained by base path '%s'", repoFilePath, basePath) + } + + return &RevisionDocumentContext{ + RepoRoot: repoRoot, + RepoFilePath: repoFilePath, + SpecFilePath: specPath, + BasePath: basePath, + BaseURL: baseURLOverride, + DocumentRewriter: NewRepoRelativeDocumentPathRewriter(repoRoot, basePath), + }, nil +} + +func BuildRevisionDocumentConfiguration(ctx *RevisionDocumentContext, revision string, + baseDocConfig *datamodel.DocumentConfiguration, +) (*datamodel.DocumentConfiguration, error) { + if ctx == nil { + return nil, fmt.Errorf("revision document context is required") + } + + docConfig := cloneDocumentConfiguration(baseDocConfig) + docConfig.AllowFileReferences = true + docConfig.BasePath = ctx.BasePath + docConfig.SpecFilePath = ctx.SpecFilePath + if ctx.BaseURL != nil { + docConfig.BaseURL = ctx.BaseURL + docConfig.AllowRemoteReferences = true + } + + revisionFS, err := NewGitRevisionFS(ctx.RepoRoot, ctx.BasePath, revision, docConfig) + if err != nil { + return nil, err + } + docConfig.LocalFS = revisionFS + return docConfig, nil +} + +func cloneDocumentConfiguration(base *datamodel.DocumentConfiguration) *datamodel.DocumentConfiguration { + if base == nil { + return datamodel.NewDocumentConfiguration() + } + cloned := *base + cloned.LocalFS = nil + return &cloned +} + +func normalizeRevisionRepoPath(repoDir, repoRoot, filePath string) (string, error) { + repoRoot, err := CanonicalizePath(repoRoot) + if err != nil { + return "", fmt.Errorf("cannot canonicalize repository root '%s': %w", repoRoot, err) + } + + var absPath string + switch { + case filepath.IsAbs(filePath): + absPath, err = CanonicalizePath(filePath) + if err != nil { + return "", fmt.Errorf("cannot canonicalize revision path '%s': %w", filePath, err) + } + default: + absPath, err = CanonicalizePath(filepath.Join(repoDir, filepath.Clean(filePath))) + if err != nil { + return "", fmt.Errorf("cannot canonicalize revision path '%s': %w", filePath, err) + } + } + + relPath, err := filepath.Rel(repoRoot, absPath) + if err != nil { + return "", fmt.Errorf("cannot normalize revision path '%s': %w", filePath, err) + } + if relPath == "." || relPath == "" { + return "", fmt.Errorf("revision path '%s' must point to a file", filePath) + } + if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("revision path '%s' resolves outside repository root '%s'", filePath, repoRoot) + } + return filepath.ToSlash(relPath), nil +} + +func CanonicalizePath(path string) (string, error) { + absPath, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + + existingPath := absPath + var missingParts []string + for { + if _, err := os.Lstat(existingPath); err == nil { + break + } else if !os.IsNotExist(err) { + return "", err + } + + parent := filepath.Dir(existingPath) + if parent == existingPath { + return absPath, nil + } + missingParts = append([]string{filepath.Base(existingPath)}, missingParts...) + existingPath = parent + } + + resolvedPath, err := filepath.EvalSymlinks(existingPath) + if err != nil { + return "", err + } + for _, part := range missingParts { + resolvedPath = filepath.Join(resolvedPath, part) + } + return resolvedPath, nil +} diff --git a/git/revision_fs.go b/git/revision_fs.go new file mode 100644 index 0000000..4a56290 --- /dev/null +++ b/git/revision_fs.go @@ -0,0 +1,395 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: Apache-2.0 + +package git + +import ( + "context" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/libopenapi/index" + "go.yaml.in/yaml/v4" +) + +var revisionFSReadFileAtRevision = ReadFileAtRevision + +type GitRevisionFS struct { + repoRoot string + baseDir string + revision string + indexConfig *index.SpecIndexConfig + logger *slog.Logger + rolodex *index.Rolodex + + files sync.Map + processing sync.Map +} + +type gitRevisionFile struct { + name string + fullPath string + extension index.FileExtension + data []byte + lastModified time.Time + errors []error + index atomic.Value + indexErr atomic.Value + parsed *yaml.Node + offset int64 + readMu sync.Mutex + parseMu sync.Mutex + indexOnce sync.Once +} + +type gitRevisionWaiter struct { + file *gitRevisionFile + err error + done bool + mu sync.Mutex + cond *sync.Cond +} + +func NewGitRevisionFS(repoRoot, baseDir, revision string, docConfig *datamodel.DocumentConfiguration) (*GitRevisionFS, error) { + absRepoRoot, err := filepath.Abs(repoRoot) + if err != nil { + return nil, fmt.Errorf("cannot resolve repository root '%s': %w", repoRoot, err) + } + absBaseDir, err := filepath.Abs(baseDir) + if err != nil { + return nil, fmt.Errorf("cannot resolve base directory '%s': %w", baseDir, err) + } + relBaseDir, err := filepath.Rel(absRepoRoot, absBaseDir) + if err != nil { + return nil, fmt.Errorf("cannot relate base directory '%s' to repository root '%s': %w", absBaseDir, absRepoRoot, err) + } + if relBaseDir == ".." || strings.HasPrefix(relBaseDir, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("base directory '%s' resolves outside repository root '%s'", absBaseDir, absRepoRoot) + } + var logger *slog.Logger + var indexConfig *index.SpecIndexConfig + if docConfig != nil { + logger = docConfig.Logger + indexConfig = &index.SpecIndexConfig{ + BaseURL: docConfig.BaseURL, + BasePath: absBaseDir, + SpecFilePath: docConfig.SpecFilePath, + AllowRemoteLookup: docConfig.AllowRemoteReferences, + AllowFileLookup: true, + IgnorePolymorphicCircularReferences: docConfig.IgnorePolymorphicCircularReferences, + IgnoreArrayCircularReferences: docConfig.IgnoreArrayCircularReferences, + AvoidCircularReferenceCheck: docConfig.SkipCircularReferenceCheck, + UseSchemaQuickHash: true, + SkipDocumentCheck: true, + Logger: docConfig.Logger, + } + } + return &GitRevisionFS{ + repoRoot: filepath.Clean(absRepoRoot), + baseDir: filepath.Clean(absBaseDir), + revision: revision, + logger: logger, + indexConfig: indexConfig, + }, nil +} + +func (g *GitRevisionFS) SetRolodex(rolodex *index.Rolodex) { + g.rolodex = rolodex +} + +func (g *GitRevisionFS) SetLogger(logger *slog.Logger) { + g.logger = logger +} + +func (g *GitRevisionFS) GetFiles() map[string]index.RolodexFile { + files := make(map[string]index.RolodexFile) + g.files.Range(func(key, value any) bool { + files[key.(string)] = value.(*gitRevisionFile) + return true + }) + return files +} + +func (g *GitRevisionFS) Open(name string) (fs.File, error) { + virtualPath, relPath, err := g.resolveName(name) + if err != nil { + return nil, err + } + + if file, ok := g.files.Load(virtualPath); ok { + existing := file.(*gitRevisionFile) + existing.ResetRead() + return existing, nil + } + + waiter := &gitRevisionWaiter{} + waiter.cond = sync.NewCond(&waiter.mu) + actual, loaded := g.processing.LoadOrStore(virtualPath, waiter) + if loaded { + wait := actual.(*gitRevisionWaiter) + wait.mu.Lock() + for !wait.done { + wait.cond.Wait() + } + file := wait.file + err := wait.err + wait.mu.Unlock() + if file != nil { + file.ResetRead() + } + return file, err + } + wait := actual.(*gitRevisionWaiter) + defer func() { + wait.mu.Lock() + wait.done = true + wait.cond.Broadcast() + wait.mu.Unlock() + g.processing.Delete(virtualPath) + }() + + if file, ok := g.files.Load(virtualPath); ok { + existing := file.(*gitRevisionFile) + existing.ResetRead() + wait.file = existing + return existing, nil + } + + extension := index.ExtractFileType(virtualPath) + if extension == index.UNSUPPORTED { + wait.err = &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + return nil, wait.err + } + + data, err := revisionFSReadFileAtRevision(g.repoRoot, g.revision, relPath) + if err != nil { + wait.err = err + return nil, err + } + + file := &gitRevisionFile{ + name: filepath.Base(virtualPath), + fullPath: virtualPath, + extension: extension, + data: data, + lastModified: time.Now(), + } + g.files.Store(virtualPath, file) + + if err := g.indexFile(file); err != nil { + file.errors = append(file.errors, err) + } + + wait.file = file + file.ResetRead() + return file, nil +} + +func (g *GitRevisionFS) resolveName(name string) (string, string, error) { + path := filepath.FromSlash(name) + if !filepath.IsAbs(path) { + path = filepath.Join(g.baseDir, path) + } + fullPath, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", "", fmt.Errorf("cannot resolve git revision path '%s': %w", name, err) + } + baseRelPath, err := filepath.Rel(g.baseDir, fullPath) + if err != nil { + return "", "", fmt.Errorf("cannot normalize git revision path '%s': %w", name, err) + } + if baseRelPath == "." || baseRelPath == "" { + return "", "", fmt.Errorf("git revision path '%s' must point to a file", name) + } + if baseRelPath == ".." || strings.HasPrefix(baseRelPath, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("git revision path '%s' resolves outside repository root", name) + } + repoRelPath, err := filepath.Rel(g.repoRoot, fullPath) + if err != nil { + return "", "", fmt.Errorf("cannot normalize git revision path '%s': %w", name, err) + } + if repoRelPath == ".." || strings.HasPrefix(repoRelPath, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("git revision path '%s' resolves outside repository root", name) + } + return fullPath, filepath.ToSlash(repoRelPath), nil +} + +func (g *GitRevisionFS) indexFile(file *gitRevisionFile) error { + if g.indexConfig == nil { + return nil + } + copiedConfig := *g.indexConfig + copiedConfig.SpecAbsolutePath = file.fullPath + copiedConfig.AvoidBuildIndex = true + copiedConfig.SpecInfo = nil + copiedConfig.Rolodex = g.rolodex + + idx, err := file.Index(&copiedConfig) + if err != nil { + return err + } + if idx == nil { + return nil + } + if g.rolodex != nil { + idx.SetRolodex(g.rolodex) + } + resolver := index.NewResolver(idx) + if copiedConfig.IgnoreArrayCircularReferences { + resolver.IgnoreArrayCircularReferences() + } + if copiedConfig.IgnorePolymorphicCircularReferences { + resolver.IgnorePolymorphicCircularReferences() + } + idx.BuildIndex() + if g.rolodex != nil { + g.rolodex.AddIndex(idx) + } + return nil +} + +func (f *gitRevisionFile) GetContent() string { + return string(f.data) +} + +func (f *gitRevisionFile) GetFileExtension() index.FileExtension { + return f.extension +} + +func (f *gitRevisionFile) GetFullPath() string { + return f.fullPath +} + +func (f *gitRevisionFile) GetErrors() []error { + return f.errors +} + +func (f *gitRevisionFile) GetContentAsYAMLNode() (*yaml.Node, error) { + if idx := f.GetIndex(); idx != nil && idx.GetRootNode() != nil { + return idx.GetRootNode(), nil + } + + f.parseMu.Lock() + defer f.parseMu.Unlock() + if f.parsed != nil { + return f.parsed, nil + } + if f.data == nil { + return nil, fmt.Errorf("no data to parse for file: %s", f.fullPath) + } + + var root yaml.Node + err := yaml.Unmarshal(f.data, &root) + if err != nil { + root = yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: string(f.data), + }}, + } + } + if idx := f.GetIndex(); idx != nil && idx.GetRootNode() == nil { + idx.SetRootNode(&root) + } + f.parsed = &root + return &root, err +} + +func (f *gitRevisionFile) GetIndex() *index.SpecIndex { + if value := f.index.Load(); value != nil { + return value.(*index.SpecIndex) + } + return nil +} + +func (f *gitRevisionFile) WaitForIndexing() {} + +func (f *gitRevisionFile) Name() string { + return f.name +} + +func (f *gitRevisionFile) ModTime() time.Time { + return f.lastModified +} + +func (f *gitRevisionFile) IsDir() bool { + return false +} + +func (f *gitRevisionFile) Sys() any { + return nil +} + +func (f *gitRevisionFile) Size() int64 { + return int64(len(f.data)) +} + +func (f *gitRevisionFile) Mode() os.FileMode { + return 0 +} + +func (f *gitRevisionFile) Close() error { + return nil +} + +func (f *gitRevisionFile) Stat() (fs.FileInfo, error) { + return f, nil +} + +func (f *gitRevisionFile) Read(p []byte) (int, error) { + f.readMu.Lock() + defer f.readMu.Unlock() + if f.offset >= int64(len(f.data)) { + return 0, io.EOF + } + n := copy(p, f.data[f.offset:]) + f.offset += int64(n) + return n, nil +} + +func (f *gitRevisionFile) ResetRead() { + f.readMu.Lock() + f.offset = 0 + f.readMu.Unlock() +} + +func (f *gitRevisionFile) Index(config *index.SpecIndexConfig) (*index.SpecIndex, error) { + f.indexOnce.Do(func() { + info, err := datamodel.ExtractSpecInfoWithDocumentCheck(f.data, true) + if err != nil { + f.indexErr.Store(err) + return + } + if info == nil || info.RootNode == nil { + f.indexErr.Store(fmt.Errorf("failed to extract spec info from file: %s", f.fullPath)) + return + } + if config.SpecInfo == nil { + config.SpecInfo = info + } + idx := index.NewSpecIndexWithConfigAndContext(context.Background(), info.RootNode, config) + idx.SetAbsolutePath(f.fullPath) + f.index.Store(idx) + }) + + var idx *index.SpecIndex + if value := f.index.Load(); value != nil { + idx = value.(*index.SpecIndex) + } + var err error + if value := f.indexErr.Load(); value != nil { + err = value.(error) + } + return idx, err +} diff --git a/git/revision_fs_test.go b/git/revision_fs_test.go new file mode 100644 index 0000000..aca8813 --- /dev/null +++ b/git/revision_fs_test.go @@ -0,0 +1,184 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: Apache-2.0 + +package git + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/pb33f/libopenapi/datamodel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitRevisionFS_OpenLoadsRevisionScopedFiles(t *testing.T) { + repoDir, specDir := createExplodedRevisionRepo(t) + + docConfig := datamodel.NewDocumentConfiguration() + docConfig.BasePath = repoDir + docConfig.AllowFileReferences = true + + beforeFS, err := NewGitRevisionFS(repoDir, repoDir, "HEAD~1", docConfig) + require.NoError(t, err) + afterFS, err := NewGitRevisionFS(repoDir, repoDir, "HEAD", docConfig) + require.NoError(t, err) + + beforeFile, err := beforeFS.Open(filepath.ToSlash(filepath.Join("apis", "components", "pet.yaml"))) + require.NoError(t, err) + afterFile, err := afterFS.Open(filepath.ToSlash(filepath.Join("apis", "components", "pet.yaml"))) + require.NoError(t, err) + + beforeBits := mustReadAllFS(t, beforeFile) + afterBits := mustReadAllFS(t, afterFile) + + assert.NotContains(t, string(beforeBits), "name:") + assert.Contains(t, string(afterBits), "name:") + assert.NotEqual(t, string(beforeBits), string(afterBits)) + + rootFile, err := afterFS.Open(filepath.ToSlash(filepath.Join("apis", "openapi.yaml"))) + require.NoError(t, err) + rootBits := mustReadAllFS(t, rootFile) + assert.Contains(t, string(rootBits), "./components/pet.yaml") + + relativeFS, err := NewGitRevisionFS(repoDir, specDir, "HEAD", docConfig) + require.NoError(t, err) + relativeFile, err := relativeFS.Open(filepath.ToSlash(filepath.Join("components", "pet.yaml"))) + require.NoError(t, err) + assert.Contains(t, string(mustReadAllFS(t, relativeFile)), "name:") +} + +func TestGitRevisionFS_CachesRepeatedOpens(t *testing.T) { + repoDir, _ := createExplodedRevisionRepo(t) + docConfig := datamodel.NewDocumentConfiguration() + docConfig.BasePath = repoDir + docConfig.AllowFileReferences = true + + revisionFS, err := NewGitRevisionFS(repoDir, repoDir, "HEAD", docConfig) + require.NoError(t, err) + + first, err := revisionFS.Open(filepath.ToSlash(filepath.Join("apis", "components", "pet.yaml"))) + require.NoError(t, err) + second, err := revisionFS.Open(filepath.ToSlash(filepath.Join("apis", "components", "pet.yaml"))) + require.NoError(t, err) + + assert.Same(t, first, second) +} + +func TestGitRevisionFS_RejectsOutsideRepo(t *testing.T) { + repoDir, _ := createExplodedRevisionRepo(t) + docConfig := datamodel.NewDocumentConfiguration() + docConfig.BasePath = repoDir + docConfig.AllowFileReferences = true + + revisionFS, err := NewGitRevisionFS(repoDir, repoDir, "HEAD", docConfig) + require.NoError(t, err) + + _, err = revisionFS.Open("../outside.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "resolves outside repository root") +} + +func TestGitRevisionFS_RetriesAfterReadError(t *testing.T) { + repoDir, _ := createExplodedRevisionRepo(t) + docConfig := datamodel.NewDocumentConfiguration() + docConfig.BasePath = repoDir + docConfig.AllowFileReferences = true + + revisionFS, err := NewGitRevisionFS(repoDir, repoDir, "HEAD", docConfig) + require.NoError(t, err) + + originalRead := revisionFSReadFileAtRevision + t.Cleanup(func() { + revisionFSReadFileAtRevision = originalRead + }) + + var calls int + revisionFSReadFileAtRevision = func(repoDir, revision, filePath string) ([]byte, error) { + calls++ + return nil, errors.New("boom") + } + + _, err = revisionFS.Open(filepath.ToSlash(filepath.Join("apis", "components", "pet.yaml"))) + require.Error(t, err) + _, err = revisionFS.Open(filepath.ToSlash(filepath.Join("apis", "components", "pet.yaml"))) + require.Error(t, err) + assert.Equal(t, 2, calls) +} + +func createExplodedRevisionRepo(t *testing.T) (string, string) { + t.Helper() + + repoDir := t.TempDir() + runGit(t, repoDir, "init") + runGit(t, repoDir, "config", "user.name", "Test User") + runGit(t, repoDir, "config", "user.email", "test@example.com") + + specDir := filepath.Join(repoDir, "apis") + componentDir := filepath.Join(specDir, "components") + require.NoError(t, os.MkdirAll(componentDir, 0o755)) + + rootPath := filepath.Join(specDir, "openapi.yaml") + componentPath := filepath.Join(componentDir, "pet.yaml") + + root := `openapi: 3.0.3 +info: + title: Exploded + version: "1.0" +paths: + /pets: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "./components/pet.yaml#/components/schemas/Pet" +` + firstComponent := `components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: integer +` + secondComponent := `components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string +` + + require.NoError(t, os.WriteFile(rootPath, []byte(root), 0o644)) + require.NoError(t, os.WriteFile(componentPath, []byte(firstComponent), 0o644)) + runGit(t, repoDir, "add", "apis/openapi.yaml", "apis/components/pet.yaml") + runGit(t, repoDir, "commit", "-m", "first") + + require.NoError(t, os.WriteFile(componentPath, []byte(secondComponent), 0o644)) + runGit(t, repoDir, "add", "apis/components/pet.yaml") + runGit(t, repoDir, "commit", "-m", "second") + + return repoDir, specDir +} + +func mustReadAllFS(t *testing.T, file interface{ Read([]byte) (int, error) }) []byte { + t.Helper() + + bits, err := io.ReadAll(file) + require.NoError(t, err) + return bits +} diff --git a/go.mod b/go.mod index 5ca78e7..38e1ad6 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,8 @@ require ( github.com/google/uuid v1.6.0 github.com/mattn/go-runewidth v0.0.23 github.com/muesli/termenv v0.16.0 - github.com/pb33f/doctor v0.0.50 - github.com/pb33f/libopenapi v0.36.0 + github.com/pb33f/doctor v0.0.51 + github.com/pb33f/libopenapi v0.36.1 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 5cfe3cd..11b79d4 100644 --- a/go.sum +++ b/go.sum @@ -94,10 +94,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pb33f/doctor v0.0.50 h1:9DF7Ff+VyShWvyesF6ab2/g7KuxB5DqfsKsgPEKDgPg= github.com/pb33f/doctor v0.0.50/go.mod h1:a/X9otAs4JJEgRuCCNldwesU80ykIrsTN/l7fZLvamo= +github.com/pb33f/doctor v0.0.51 h1:q2qqPFnmSvo5mFSRWmWM5fvrYaIA29yOT/iVWDkPMa0= +github.com/pb33f/doctor v0.0.51/go.mod h1:B06Wq2Z9E8ERbvHFsrF03Js/JCfjxpL3OV9fF3c/O9w= github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y= github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo= github.com/pb33f/libopenapi v0.36.0 h1:kQ73f8H0iXOj9naLxwYbKmgOBBXonJIsQbIztYjQ2lc= github.com/pb33f/libopenapi v0.36.0/go.mod h1:MsDdUlQ1CdrIDO5v26JfgBxQs7kcaOUEpMP3EqU6bI4= +github.com/pb33f/libopenapi v0.36.1 h1:CNZ52e+/W9fA1kAgL8EePDQQrKPfN9+HdLR6XAxUEpw= +github.com/pb33f/libopenapi v0.36.1/go.mod h1:MsDdUlQ1CdrIDO5v26JfgBxQs7kcaOUEpMP3EqU6bI4= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/html-report/ui/package-lock.json b/html-report/ui/package-lock.json index 39a59a6..77241c7 100644 --- a/html-report/ui/package-lock.json +++ b/html-report/ui/package-lock.json @@ -8,7 +8,7 @@ "name": "@pb33f/openapi-changes-report-ui", "version": "0.0.1", "dependencies": { - "@pb33f/cowboy-components": "^0.13.0", + "@pb33f/cowboy-components": "^0.13.1", "@shoelace-style/shoelace": "^2.20.1", "chart.js": "^4.5.1", "lit": "^3.3.2" @@ -18,60 +18,14 @@ "elkjs": "^0.9.3", "jsdom": "^29.0.2", "typescript": "^5.9.3", - "vite": "^8.0.1", + "vite": "^8.0.8", "vitest": "^4.1.2" } }, - "../../../cowboy-components": { - "name": "@pb33f/cowboy-components", - "version": "0.0.0", - "extraneous": true, - "license": "BUSL-1.1", - "dependencies": { - "@pb33f/ranch": "^0.7.0", - "@pb33f/saddlebag": "^0.2.3", - "@shoelace-style/shoelace": "^2.20.1", - "@vaadin/router": "^2.0.0", - "chart.js": "^4.4.8", - "d3-shape": "^3.2.0", - "diff-match-patch": "^1.0.5", - "elkjs": "^0.9.3", - "marked": "^11.1.1", - "mermaid": "^11.14.0", - "monaco-editor": "^0.52.0", - "monaco-yaml": "^5.3.1", - "prismjs": "1.30.0", - "snapsvg-cjs": "^0.0.6", - "web-worker": "^1.3.0" - }, - "devDependencies": { - "@open-wc/testing": "^4.0.0", - "@pb33f/wiretap": "^0.5.1", - "@rollup/plugin-typescript": "^11.1.5", - "@types/d3-shape": "^3.1.6", - "@types/diff-match-patch": "^1.0.36", - "@types/node": "^20.10.6", - "@types/prismjs": "^1.26.3", - "@types/snapsvg": "^0.5.8", - "@vitest/coverage-v8": "^4.0.18", - "@web/test-runner": "^0.20.2", - "@web/test-runner-playwright": "^0.11.1", - "jsdom": "^27.4.0", - "rollup-plugin-copy": "^3.5.0", - "typescript": "^5.3.3", - "vite": "7.1.7", - "vite-plugin-dts": "^3.7.0", - "vite-plugin-mkcert": "^1.17.5", - "vite-plugin-monaco-editor": "^1.1.0", - "vite-tsconfig-paths": "^4.2.3", - "vitest": "^4.0.18" - } - }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", - "license": "MIT", "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" @@ -147,8 +101,7 @@ "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", - "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", - "license": "MIT" + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==" }, "node_modules/@bramus/specificity": { "version": "2.4.2", @@ -167,7 +120,6 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", - "license": "Apache-2.0", "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" @@ -177,7 +129,6 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", - "license": "Apache-2.0", "dependencies": { "@chevrotain/types": "12.0.0" } @@ -185,20 +136,17 @@ "node_modules/@chevrotain/regexp-to-ast": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", - "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", - "license": "Apache-2.0" + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==" }, "node_modules/@chevrotain/types": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", - "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", - "license": "Apache-2.0" + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==" }, "node_modules/@chevrotain/utils": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", - "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", - "license": "Apache-2.0" + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==" }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", @@ -453,14 +401,12 @@ "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "license": "MIT" + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==" }, "node_modules/@iconify/utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", - "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", @@ -508,7 +454,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", - "license": "MIT", "dependencies": { "langium": "^4.0.0" } @@ -644,10 +589,9 @@ } }, "node_modules/@pb33f/cowboy-components": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@pb33f/cowboy-components/-/cowboy-components-0.13.0.tgz", - "integrity": "sha512-1c+Kt0stti+CiWcq9z5zLycuBYoCvISvkqB7hWOMU5bazK35n3YvQCHyPGv+Hytd8/COOHXYYf7iQPy3gnU3jw==", - "license": "BUSL-1.1", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@pb33f/cowboy-components/-/cowboy-components-0.13.1.tgz", + "integrity": "sha512-1m0gTeaT8TVj3r23oh8EVMro5F06riHtW5u1NH5GqMlMB4rSKXOToMQ2+zHft5P0MsNVY9dvaylm5TulZYAoAQ==", "dependencies": { "@pb33f/ranch": "^0.7.0", "@pb33f/saddlebag": "^0.2.3", @@ -670,7 +614,6 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/@pb33f/ranch/-/ranch-0.7.0.tgz", "integrity": "sha512-kaT8RDcKjf609lGRXVnau/Rb0pARgwrLkmTbWD6AVpAMpup/VgluwthjnEJSQJR78L+eDT8SF+axw2K+wEH10g==", - "license": "MIT", "dependencies": { "@stomp/stompjs": "^7.1.1" } @@ -678,8 +621,7 @@ "node_modules/@pb33f/saddlebag": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@pb33f/saddlebag/-/saddlebag-0.2.3.tgz", - "integrity": "sha512-GUJrvYYoXSHLRZUyDHA5BC55iIws00c8ADcCxOaof8jetMO6+EHUXHyc8umg53wq4r8P3f53EtVyYCPi+6BTKg==", - "license": "MIT" + "integrity": "sha512-GUJrvYYoXSHLRZUyDHA5BC55iIws00c8ADcCxOaof8jetMO6+EHUXHyc8umg53wq4r8P3f53EtVyYCPi+6BTKg==" }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.15", @@ -994,8 +936,7 @@ "node_modules/@stomp/stompjs": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", - "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", - "license": "Apache-2.0" + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==" }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", @@ -1109,7 +1050,6 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", - "license": "MIT", "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", @@ -1146,14 +1086,12 @@ "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" }, "node_modules/@types/d3-axis": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", - "license": "MIT", "dependencies": { "@types/d3-selection": "*" } @@ -1162,7 +1100,6 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", - "license": "MIT", "dependencies": { "@types/d3-selection": "*" } @@ -1170,20 +1107,17 @@ "node_modules/@types/d3-chord": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", - "license": "MIT" + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, "node_modules/@types/d3-contour": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "license": "MIT", "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" @@ -1192,20 +1126,17 @@ "node_modules/@types/d3-delaunay": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", - "license": "MIT" + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" }, "node_modules/@types/d3-dispatch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", - "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", - "license": "MIT" + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==" }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", "dependencies": { "@types/d3-selection": "*" } @@ -1213,20 +1144,17 @@ "node_modules/@types/d3-dsv": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", - "license": "MIT" + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==" }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" }, "node_modules/@types/d3-fetch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", - "license": "MIT", "dependencies": { "@types/d3-dsv": "*" } @@ -1234,20 +1162,17 @@ "node_modules/@types/d3-force": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", - "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", - "license": "MIT" + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==" }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", - "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", - "license": "MIT" + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==" }, "node_modules/@types/d3-geo": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "license": "MIT", "dependencies": { "@types/geojson": "*" } @@ -1255,14 +1180,12 @@ "node_modules/@types/d3-hierarchy": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", - "license": "MIT" + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", "dependencies": { "@types/d3-color": "*" } @@ -1270,32 +1193,27 @@ "node_modules/@types/d3-path": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" }, "node_modules/@types/d3-polygon": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", - "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", - "license": "MIT" + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==" }, "node_modules/@types/d3-quadtree": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", - "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", - "license": "MIT" + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==" }, "node_modules/@types/d3-random": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", - "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", - "license": "MIT" + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", "dependencies": { "@types/d3-time": "*" } @@ -1303,20 +1221,17 @@ "node_modules/@types/d3-scale-chromatic": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "license": "MIT" + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==" }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", "dependencies": { "@types/d3-path": "*" } @@ -1324,26 +1239,22 @@ "node_modules/@types/d3-time": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" }, "node_modules/@types/d3-time-format": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", - "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", - "license": "MIT" + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==" }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", "dependencies": { "@types/d3-selection": "*" } @@ -1352,7 +1263,6 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" @@ -1407,8 +1317,7 @@ "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==" }, "node_modules/@types/http-assert": { "version": "1.5.6", @@ -1595,7 +1504,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", - "license": "MIT", "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" @@ -1605,7 +1513,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/@vaadin/router/-/router-2.0.1.tgz", "integrity": "sha512-Rs3FQhc8CECrigF8dbaQk7Q3+/BXIjUMd5vapMIphW09WdDxZB88yQ1C/WXV08Z2skPqMP+6ooLde6ItjygleQ==", - "license": "Apache-2.0", "dependencies": { "@vaadin/vaadin-usage-statistics": "^2.1.3", "path-to-regexp": "^6.3.0", @@ -1616,7 +1523,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", - "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" }, @@ -1630,15 +1536,13 @@ "node_modules/@vaadin/vaadin-development-mode-detector": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz", - "integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==", - "license": "Apache-2.0" + "integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==" }, "node_modules/@vaadin/vaadin-usage-statistics": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@vaadin/vaadin-usage-statistics/-/vaadin-usage-statistics-2.1.3.tgz", "integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==", "hasInstallScript": true, - "license": "Apache-2.0", "dependencies": { "@vaadin/vaadin-development-mode-detector": "^2.0.0" }, @@ -1919,7 +1823,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -2123,7 +2026,6 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", - "license": "Apache-2.0", "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -2139,7 +2041,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz", "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==", - "license": "MIT", "dependencies": { "lodash-es": "^4.17.21" }, @@ -2238,7 +2139,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", "engines": { "node": ">= 10" } @@ -2255,8 +2155,7 @@ "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "license": "MIT" + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==" }, "node_modules/content-disposition": { "version": "0.5.4", @@ -2306,7 +2205,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", - "license": "MIT", "dependencies": { "layout-base": "^1.0.0" } @@ -2351,7 +2249,6 @@ "version": "3.33.2", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz", "integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==", - "license": "MIT", "engines": { "node": ">=0.10" } @@ -2360,7 +2257,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", - "license": "MIT", "dependencies": { "cose-base": "^1.0.0" }, @@ -2372,7 +2268,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", - "license": "MIT", "dependencies": { "cose-base": "^2.2.0" }, @@ -2384,7 +2279,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", - "license": "MIT", "dependencies": { "layout-base": "^2.0.0" } @@ -2392,14 +2286,12 @@ "node_modules/cytoscape-fcose/node_modules/layout-base": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", - "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", - "license": "MIT" + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==" }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "license": "ISC", "dependencies": { "d3-array": "3", "d3-axis": "3", @@ -2440,7 +2332,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", "dependencies": { "internmap": "1 - 2" }, @@ -2452,7 +2343,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2461,7 +2351,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", @@ -2477,7 +2366,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", "dependencies": { "d3-path": "1 - 3" }, @@ -2489,7 +2377,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2498,7 +2385,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", "dependencies": { "d3-array": "^3.2.0" }, @@ -2510,7 +2396,6 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", "dependencies": { "delaunator": "5" }, @@ -2522,7 +2407,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2531,7 +2415,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" @@ -2544,7 +2427,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", "dependencies": { "commander": "7", "iconv-lite": "0.6", @@ -2569,7 +2451,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -2581,7 +2462,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", "engines": { "node": ">=12" } @@ -2590,7 +2470,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "license": "ISC", "dependencies": { "d3-dsv": "1 - 3" }, @@ -2602,7 +2481,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", @@ -2616,7 +2494,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2625,7 +2502,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" }, @@ -2637,7 +2513,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2646,7 +2521,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", "dependencies": { "d3-color": "1 - 3" }, @@ -2658,7 +2532,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2667,7 +2540,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2676,7 +2548,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2685,7 +2556,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2694,7 +2564,6 @@ "version": "0.12.3", "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "license": "BSD-3-Clause", "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" @@ -2704,7 +2573,6 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", "dependencies": { "internmap": "^1.0.0" } @@ -2712,14 +2580,12 @@ "node_modules/d3-sankey/node_modules/d3-path": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" }, "node_modules/d3-sankey/node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", "dependencies": { "d3-path": "1" } @@ -2727,14 +2593,12 @@ "node_modules/d3-sankey/node_modules/internmap": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", @@ -2750,7 +2614,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" @@ -2763,7 +2626,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2772,7 +2634,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", "dependencies": { "d3-path": "^3.1.0" }, @@ -2784,7 +2645,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", "dependencies": { "d3-array": "2 - 3" }, @@ -2796,7 +2656,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", "dependencies": { "d3-time": "1 - 3" }, @@ -2808,7 +2667,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", "engines": { "node": ">=12" } @@ -2817,7 +2675,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", @@ -2836,7 +2693,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", @@ -2852,7 +2708,6 @@ "version": "7.0.14", "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", - "license": "MIT", "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" @@ -2875,8 +2730,7 @@ "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT" + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==" }, "node_modules/debounce": { "version": "1.2.1", @@ -2944,7 +2798,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" } @@ -3000,8 +2853,7 @@ "node_modules/diff-match-patch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", - "license": "Apache-2.0" + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" }, "node_modules/dir-glob": { "version": "3.0.1", @@ -3020,7 +2872,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", - "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -3160,8 +3011,7 @@ "node_modules/eve": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/eve/-/eve-0.5.4.tgz", - "integrity": "sha512-aqprQ9MAOh1t66PrHxDFmMXPlgNO6Uv1uqvxmwjprQV50jaQ2RqO7O1neY4PJwC+hMnkyMDphu2AQPOPZdjQog==", - "license": "Apache-2.0" + "integrity": "sha512-aqprQ9MAOh1t66PrHxDFmMXPlgNO6Uv1uqvxmwjprQV50jaQ2RqO7O1neY4PJwC+hMnkyMDphu2AQPOPZdjQog==" }, "node_modules/execa": { "version": "5.1.1", @@ -3402,8 +3252,7 @@ "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", - "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", - "license": "MIT" + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==" }, "node_modules/has-flag": { "version": "4.0.0", @@ -3591,7 +3440,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", "engines": { "node": ">=12" } @@ -3893,8 +3741,7 @@ "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "license": "MIT" + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" }, "node_modules/katex": { "version": "0.16.45", @@ -3904,7 +3751,6 @@ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], - "license": "MIT", "dependencies": { "commander": "^8.3.0" }, @@ -3916,7 +3762,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", "engines": { "node": ">= 12" } @@ -4048,7 +3893,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz", "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==", - "license": "MIT", "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", @@ -4065,8 +3909,7 @@ "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", - "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", - "license": "MIT" + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" }, "node_modules/lightningcss": { "version": "1.32.0", @@ -4363,8 +4206,7 @@ "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" }, "node_modules/log-update": { "version": "4.0.0", @@ -4425,7 +4267,6 @@ "version": "11.2.0", "resolved": "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz", "integrity": "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==", - "license": "MIT", "bin": { "marked": "bin/marked.js" }, @@ -4481,7 +4322,6 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", - "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", @@ -4510,7 +4350,6 @@ "version": "16.4.2", "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", - "license": "MIT", "bin": { "marked": "bin/marked.js" }, @@ -4595,7 +4434,6 @@ "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "license": "MIT", "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", @@ -4606,14 +4444,12 @@ "node_modules/monaco-editor": { "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", - "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT" + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==" }, "node_modules/monaco-languageserver-types": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz", "integrity": "sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==", - "license": "MIT", "dependencies": { "monaco-types": "^0.1.0", "vscode-languageserver-protocol": "^3.0.0", @@ -4627,7 +4463,6 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/monaco-marker-data-provider/-/monaco-marker-data-provider-1.2.5.tgz", "integrity": "sha512-5ZdcYukhPwgYMCvlZ9H5uWs5jc23BQ8fFF5AhSIdrz5mvYLsqGZ58ZLxTv8rCX6+AxdJ8+vxg1HVSk+F2bLosg==", - "license": "MIT", "dependencies": { "monaco-types": "^0.1.0" }, @@ -4639,7 +4474,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.2.tgz", "integrity": "sha512-8LwfrlWXsedHwAL41xhXyqzPibS8IqPuIXr9NdORhonS495c2/wky+sI1PRLvMCuiI0nqC2NH1six9hdiRY4Xg==", - "license": "MIT", "funding": { "url": "https://github.com/sponsors/remcohaszing" } @@ -4648,7 +4482,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/monaco-worker-manager/-/monaco-worker-manager-2.0.1.tgz", "integrity": "sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==", - "license": "MIT", "peerDependencies": { "monaco-editor": ">=0.30.0" } @@ -4657,10 +4490,6 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.4.1.tgz", "integrity": "sha512-YQ6d/Ei98Uk073SJLFbwuSi95qhnl8F8NNmIUqN2XhDt9psZN2LqQ1T7pPQ866NJb2wFj44IrjnANgpa2jTfag==", - "license": "MIT", - "workspaces": [ - "examples/*" - ], "dependencies": { "jsonc-parser": "^3.0.0", "monaco-languageserver-types": "^0.4.0", @@ -4856,8 +4685,7 @@ "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", - "license": "MIT" + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==" }, "node_modules/parse5": { "version": "6.0.1", @@ -4879,14 +4707,12 @@ "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "license": "MIT" + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", - "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", - "license": "MIT" + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==" }, "node_modules/path-is-absolute": { "version": "1.0.1", @@ -4911,8 +4737,7 @@ "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "license": "MIT" + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" }, "node_modules/path-type": { "version": "4.0.0", @@ -4954,7 +4779,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "license": "MIT", "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", @@ -4964,14 +4788,12 @@ "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", - "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", - "license": "MIT" + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==" }, "node_modules/points-on-path": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", - "license": "MIT", "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" @@ -5010,7 +4832,6 @@ "version": "3.8.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -5025,7 +4846,6 @@ "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", "engines": { "node": ">=6" } @@ -5236,8 +5056,7 @@ "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "license": "Unlicense" + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==" }, "node_modules/rolldown": { "version": "1.0.0-rc.15", @@ -5277,7 +5096,6 @@ "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", - "license": "MIT", "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", @@ -5312,8 +5130,7 @@ "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, "node_modules/safe-buffer": { "version": "5.2.1", @@ -5538,7 +5355,6 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/snapsvg/-/snapsvg-0.5.1.tgz", "integrity": "sha512-CjwWYsL7+CCk1vCk9BBKGYS4WJVDfJAOMWU+Zhzf8wf6pAm/xT34wnpaMPAgcgCNkxuU6OkQPPd8wGuRCY9aNw==", - "license": "Apache-2.0", "dependencies": { "eve": "~0.5.1" } @@ -5547,7 +5363,6 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/snapsvg-cjs/-/snapsvg-cjs-0.0.6.tgz", "integrity": "sha512-7NNvoGrc3BQvWz5rWK1DsD5/Vni4STswz5B3JrBADboQWcN8OBVGjYVJFPT5JkUXb2iVnEflZANhufEpEcTHXw==", - "license": "MIT", "dependencies": { "snapsvg": "0.5.1" }, @@ -5640,8 +5455,7 @@ "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "license": "MIT" + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==" }, "node_modules/supports-color": { "version": "7.2.0", @@ -5667,7 +5481,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "license": "MIT", "engines": { "node": ">=20" }, @@ -5791,7 +5604,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "license": "MIT", "engines": { "node": ">=6.10" } @@ -5858,8 +5670,7 @@ "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "license": "MIT" + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==" }, "node_modules/undici": { "version": "7.24.7", @@ -5896,7 +5707,6 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { "uuid": "dist/esm/bin/uuid" } @@ -6075,7 +5885,6 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -6084,7 +5893,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, @@ -6096,7 +5904,6 @@ "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "license": "MIT", "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" @@ -6105,20 +5912,17 @@ "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "license": "MIT" + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", @@ -6136,8 +5940,7 @@ "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", - "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", - "license": "Apache-2.0" + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==" }, "node_modules/webidl-conversions": { "version": "8.0.1", diff --git a/html-report/ui/package.json b/html-report/ui/package.json index ab7c11c..b806b0f 100644 --- a/html-report/ui/package.json +++ b/html-report/ui/package.json @@ -10,7 +10,7 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "@pb33f/cowboy-components": "^0.13.0", + "@pb33f/cowboy-components": "^0.13.1", "@shoelace-style/shoelace": "^2.20.1", "chart.js": "^4.5.1", "lit": "^3.3.2" diff --git a/internal/testutil/gitrepo.go b/internal/testutil/gitrepo.go new file mode 100644 index 0000000..1782035 --- /dev/null +++ b/internal/testutil/gitrepo.go @@ -0,0 +1,110 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func RunGit(t testing.TB, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(out)) +} + +func CreateMovedRefGitSpecRepo(t testing.TB) (string, string) { + t.Helper() + + repoDir := t.TempDir() + + RunGit(t, repoDir, "init") + RunGit(t, repoDir, "config", "user.name", "Test User") + RunGit(t, repoDir, "config", "user.email", "test@example.com") + + specPath := filepath.Join(repoDir, "spec.yaml") + commonOnePath := filepath.Join(repoDir, "common1.yaml") + commonTwoPath := filepath.Join(repoDir, "common2.yaml") + + firstSpec := `openapi: 3.0.3 +info: + title: Moved Ref + version: "1.0" +paths: + /thing: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "./common1.yaml#/components/schemas/Foo" +` + secondSpec := `openapi: 3.0.3 +info: + title: Moved Ref + version: "1.1" +paths: + /thing: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "./common2.yaml#/components/schemas/Foo" +` + firstCommonOne := `components: + schemas: + Foo: + type: object + required: + - id + properties: + id: + type: integer +` + secondCommonOne := `components: + schemas: {} +` + firstCommonTwo := `components: + schemas: {} +` + secondCommonTwo := `components: + schemas: + Foo: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string +` + + require.NoError(t, os.WriteFile(specPath, []byte(firstSpec), 0o644)) + require.NoError(t, os.WriteFile(commonOnePath, []byte(firstCommonOne), 0o644)) + require.NoError(t, os.WriteFile(commonTwoPath, []byte(firstCommonTwo), 0o644)) + RunGit(t, repoDir, "add", "spec.yaml", "common1.yaml", "common2.yaml") + RunGit(t, repoDir, "commit", "-m", "first") + + require.NoError(t, os.WriteFile(specPath, []byte(secondSpec), 0o644)) + require.NoError(t, os.WriteFile(commonOnePath, []byte(secondCommonOne), 0o644)) + require.NoError(t, os.WriteFile(commonTwoPath, []byte(secondCommonTwo), 0o644)) + RunGit(t, repoDir, "add", "spec.yaml", "common1.yaml", "common2.yaml") + RunGit(t, repoDir, "commit", "-m", "second") + + return repoDir, "spec.yaml" +} diff --git a/model/commit.go b/model/commit.go index 5a30896..d6a52f9 100644 --- a/model/commit.go +++ b/model/commit.go @@ -9,6 +9,8 @@ import ( "time" ) +type DocumentPathRewriter func(string) string + type Commit struct { CreatedAt time.Time `json:"-"` UpdatedAt time.Time `json:"-"` @@ -26,5 +28,8 @@ type Commit struct { OldDocument libopenapi.Document `gorm:"-" json:"-"` RepoDirectory string `gorm:"-" json:"-"` FilePath string `gorm:"-" json:"-"` + OriginalSource string `gorm:"-" json:"-"` + ModifiedSource string `gorm:"-" json:"-"` Synthetic bool `gorm:"-" json:"-"` + DocumentRewriters []DocumentPathRewriter `gorm:"-" json:"-"` } diff --git a/model/report.go b/model/report.go index fd1e084..0876146 100644 --- a/model/report.go +++ b/model/report.go @@ -77,6 +77,8 @@ type Report struct { type FlatReport struct { Summary map[string]*reports.Changed `json:"reportSummary"` Changes []*HashedChange `json:"changes"` + OriginalPath string `json:"originalPath,omitempty"` + ModifiedPath string `json:"modifiedPath,omitempty"` DateGenerated string `json:"dateGenerated,omitempty"` Commit *Commit `gorm:"foreignKey:ID" json:"commitDetails,omitempty"` } @@ -85,6 +87,6 @@ type FlatHistoricalReport struct { GitRepoPath string `json:"gitRepoPath"` GitFilePath string `json:"gitFilePath"` Filename string `json:"filename"` - DateGenerated string `json:"dateGenerated"` + DateGenerated string `json:"dateGenerated,omitempty"` Reports []*FlatReport `json:"reports" ` } diff --git a/tui/v2/tree.go b/tui/v2/tree.go index 7402998..a3dce82 100644 --- a/tui/v2/tree.go +++ b/tui/v2/tree.go @@ -69,10 +69,24 @@ func (t *treeModel) rebuild() { return } t.entries = make([]treeEntry, 0, 64) + rootChanges := getNodeChanges(t.root) children := t.root.Children - for i, child := range children { - isLast := i == len(children)-1 - t.flattenNode(child, 0, isLast, nil) + totalItems := len(rootChanges) + len(children) + idx := 0 + + for _, change := range rootChanges { + idx++ + t.entries = append(t.entries, treeEntry{ + node: t.root, + change: change, + depth: 0, + isLast: idx == totalItems, + }) + } + + for _, child := range children { + idx++ + t.flattenNode(child, 0, idx == totalItems, nil) } // Clamp cursor if t.cursor >= len(t.entries) { diff --git a/tui/v2/tree_test.go b/tui/v2/tree_test.go index 5dd06e4..1d31cfa 100644 --- a/tui/v2/tree_test.go +++ b/tui/v2/tree_test.go @@ -89,6 +89,36 @@ func makeTestTree() *v3.Node { return root } +func makeRootChangeTree() *v3.Node { + openapiChange := &whatChangedModel.Change{ + ChangeType: whatChangedModel.Modified, + Property: "openapi", + Original: "3.0.0", + New: "3.1.0", + Breaking: true, + } + infoChange := &whatChangedModel.Change{ + ChangeType: whatChangedModel.Modified, + Property: "version", + Original: "1.0", + New: "2.0", + } + + infoNode := &v3.Node{ + Label: "Info", + Type: "Info", + } + infoNode.AppendChange(&mockChanged{changes: []*whatChangedModel.Change{infoChange}}) + + root := &v3.Node{ + Label: "Document", + Type: "Document", + Children: []*v3.Node{infoNode}, + } + root.AppendChange(&mockChanged{changes: []*whatChangedModel.Change{openapiChange}}) + return root +} + func TestFlattenNodeTree_AllExpanded(t *testing.T) { root := makeTestTree() tm := newTreeModel(root, 20) @@ -130,6 +160,29 @@ func TestCursorStartsOnFirstLeaf(t *testing.T) { assert.Equal(t, "deprecated", entry.change.Property) } +func TestFlattenNodeTree_IncludesRootOwnedLeaves(t *testing.T) { + root := makeRootChangeTree() + tm := newTreeModel(root, 20) + + require.Len(t, tm.entries, 3) + require.NotNil(t, tm.entries[0].change) + assert.Equal(t, "openapi", tm.entries[0].change.Property) + assert.Equal(t, "Info", tm.entries[1].node.Label) + require.NotNil(t, tm.entries[2].change) + assert.Equal(t, "version", tm.entries[2].change.Property) +} + +func TestCursorStartsOnRootOwnedLeaf(t *testing.T) { + root := makeRootChangeTree() + tm := newTreeModel(root, 20) + + require.Equal(t, 0, tm.cursor) + entry := tm.selectedEntry() + require.NotNil(t, entry) + require.NotNil(t, entry.change) + assert.Equal(t, "openapi", entry.change.Property) +} + func TestMoveDown_SkipsBranchNodes(t *testing.T) { root := makeTestTree() tm := newTreeModel(root, 20)