Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ All `cmd/` implementation files use their canonical names (e.g., `cmd/summary.go
- `report` should emit semantic `path` values whenever possible.
- If semantic normalization rewrites an engine path, preserve the original path in `rawPath`.
- Parameter identity must not depend on array index positions when a stable semantic name exists.
- Historical `report` output must surface partial-success state in `metaData` instead of relying on stderr warnings alone.
- `metaData.skippedCommits` is the machine-readable list of git/GitHub history revisions that were skipped because they failed to normalize or render.

### Left/right behavior

Expand All @@ -76,6 +78,7 @@ All `cmd/` implementation files use their canonical names (e.g., `cmd/summary.go

- Mixed-success histories should limp on with warnings when at least one comparable commit renders successfully.
- `summary`, `markdown-report`, and `html-report` should fail only when every candidate commit fails to render/build.
- Historical `report` output should limp on when at least one history item renders, and must set `metaData.partial = true` when any commits were skipped.
- “No prior comparable version” and “no changes found” are distinct states.

### HTML payload integrity
Expand Down
8 changes: 8 additions & 0 deletions cmd/console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ func TestWrapConsoleStartError(t *testing.T) {
func TestConsoleCommand_NoPriorVersionText(t *testing.T) {
originalExtract := extractHistoryFromFile
originalPopulate := populateHistory
originalPopulateDetailed := populateHistoryDetailed
t.Cleanup(func() {
extractHistoryFromFile = originalExtract
populateHistory = originalPopulate
populateHistoryDetailed = originalPopulateDetailed
})

extractHistoryFromFile = func(repoDirectory, filePath string,
Expand All @@ -110,6 +112,12 @@ func TestConsoleCommand_NoPriorVersionText(t *testing.T) {
) ([]*model.Commit, []error) {
return []*model.Commit{}, nil
}
populateHistoryDetailed = func(commitHistory []*model.Commit,
progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, opts git.HistoryOptions,
breakingConfig *whatChangedModel.BreakingRulesConfig,
) (*git.HistoryBuildResult, []error) {
return &git.HistoryBuildResult{}, nil
}

cmd := testRootCmd(GetConsoleCommand(), "--no-logo", "--no-color", "..", "sample-specs/petstorev3.json")
output := captureStdout(t, func() {
Expand Down
54 changes: 54 additions & 0 deletions cmd/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
package cmd

import (
"bytes"
"fmt"
"os"
"strings"

"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"
"go.yaml.in/yaml/v4"
)

// changeratorResult owns the doctor-side resources created for a single comparison.
Expand Down Expand Up @@ -51,6 +54,9 @@ func runChangerator(commit *model.Commit, breakingConfig *whatChangedModel.Break
if err != nil {
return nil, modelBuildError("original", commitSourceLabel(commit, false), err)
}
if isSelfContainedIdenticalComparison(commit) {
return nil, nil
}

rightDrDoc := drModel.NewDrDocumentAndGraph(rightModel)
leftDrDoc := drModel.NewDrDocumentAndGraph(leftModel)
Expand Down Expand Up @@ -92,6 +98,54 @@ func runChangerator(commit *model.Commit, breakingConfig *whatChangedModel.Break
}, nil
}

func isSelfContainedIdenticalComparison(commit *model.Commit) bool {
if commit == nil || !commit.Synthetic || len(commit.Data) == 0 || len(commit.OldData) == 0 {
return false
}
if !bytes.Equal(commit.Data, commit.OldData) {
return false
}
return !rootBytesContainExternalRefs(commit.OldData) && !rootBytesContainExternalRefs(commit.Data)
}

func rootBytesContainExternalRefs(spec []byte) bool {
if len(spec) == 0 {
return false
}
var root yaml.Node
if err := yaml.Unmarshal(spec, &root); err != nil {
return true
}
return nodeContainsExternalRef(&root)
}

func nodeContainsExternalRef(node *yaml.Node) bool {
if node == nil {
return false
}
if node.Kind == yaml.MappingNode {
for i := 0; i+1 < len(node.Content); i += 2 {
key := node.Content[i]
value := node.Content[i+1]
if key != nil && key.Value == "$ref" && value != nil && value.Kind == yaml.ScalarNode {
if value.Value != "" && !strings.HasPrefix(value.Value, "#") {
return true
}
}
if nodeContainsExternalRef(value) {
return true
}
}
return false
}
for _, child := range node.Content {
if nodeContainsExternalRef(child) {
return true
}
}
return false
}

func rewriteOutputLocations(ctr *changerator.Changerator, docChanges *whatChangedModel.DocumentChanges, rewriters []model.DocumentPathRewriter) {
if len(rewriters) == 0 {
return
Expand Down
54 changes: 47 additions & 7 deletions cmd/loaders.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,17 @@ import (
)

var processGithubRepo = git.ProcessGithubRepo
var processGithubRepoDetailed = git.ProcessGithubRepoDetailed
var extractHistoryFromFile = git.ExtractHistoryFromFile
var populateHistory = git.PopulateHistory
var populateHistoryDetailed = git.PopulateHistoryDetailed
var httpGet = http.Get

type loadedHistoryResult struct {
Commits []*model.Commit
SkippedCommits []string
}

// progressDrainer drains git progress channels that use synchronous sends.
// Call close() before reading collected warnings or errors.
type progressDrainer struct {
Expand Down Expand Up @@ -116,7 +123,7 @@ func (d *progressDrainer) collectErrors(errs []error) error {
return errors.Join(all...)
}

func loadGitHubCommits(rawURL string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) ([]*model.Commit, error) {
func loadGitHubCommitsDetailed(rawURL string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) (*loadedHistoryResult, error) {
specURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
Expand All @@ -127,7 +134,7 @@ func loadGitHubCommits(rawURL string, opts summaryOpts, breakingConfig *whatChan
}

d := makeProgressDrainer()
commits, errs := processGithubRepo(user, repo, filePath,
result, errs := processGithubRepoDetailed(user, repo, filePath,
d.ProgressChan, d.ErrorChan, git.HistoryOptions{
BaseCommit: opts.baseCommit,
Limit: opts.limit,
Expand All @@ -141,13 +148,28 @@ func loadGitHubCommits(rawURL string, opts summaryOpts, breakingConfig *whatChan
return nil, err
}

if result == nil {
return nil, nil
}
commits := result.Commits
if opts.latest && len(commits) > 1 {
commits = commits[:1]
}
return commits, nil
return &loadedHistoryResult{
Commits: commits,
SkippedCommits: result.SkippedCommits,
}, nil
}

func loadGitHistoryCommits(gitPath, filePath string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) ([]*model.Commit, error) {
func loadGitHubCommits(rawURL string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) ([]*model.Commit, error) {
result, err := loadGitHubCommitsDetailed(rawURL, opts, breakingConfig)
if result == nil {
return nil, err
}
return result.Commits, err
}

func loadGitHistoryCommitsDetailed(gitPath, filePath string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) (*loadedHistoryResult, error) {
if gitPath == "" || filePath == "" {
return nil, errors.New("please supply a path to a git repo and a path to a file")
}
Expand Down Expand Up @@ -178,7 +200,7 @@ func loadGitHistoryCommits(gitPath, filePath string, opts summaryOpts, breakingC
}

populateDrainer := makeProgressDrainer()
commits, errs = populateHistory(commits,
result, errs := populateHistoryDetailed(commits,
populateDrainer.ProgressChan, populateDrainer.ErrorChan, git.HistoryOptions{
LimitTime: opts.limitTime,
Base: opts.base,
Expand All @@ -190,13 +212,31 @@ func loadGitHistoryCommits(gitPath, filePath string, opts summaryOpts, breakingC
return nil, err
}

if len(commits) == 0 {
if result == nil {
return nil, nil
}
commits = result.Commits
if len(commits) == 0 {
return &loadedHistoryResult{SkippedCommits: result.SkippedCommits}, nil
}
if opts.latest {
commits = commits[:1]
}
return commits, nil
return &loadedHistoryResult{
Commits: commits,
SkippedCommits: result.SkippedCommits,
}, nil
}

func loadGitHistoryCommits(gitPath, filePath string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) ([]*model.Commit, error) {
result, err := loadGitHistoryCommitsDetailed(gitPath, filePath, opts, breakingConfig)
if result == nil {
return nil, err
}
if len(result.Commits) == 0 {
return nil, nil
}
return result.Commits, err
}

func loadLeftRightCommits(left, right string, opts summaryOpts) ([]*model.Commit, error) {
Expand Down
117 changes: 88 additions & 29 deletions cmd/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,52 @@ func changerateCommit(commit *model.Commit, breakingConfig *whatChangedModel.Bre
return result, nil
}

func changerateAndFlatten(commits []*model.Commit, breakingConfig *whatChangedModel.BreakingRulesConfig) ([]*model.FlatReport, error) {
type historicalFlattenResult struct {
Reports []*model.FlatReport
SkippedCommits []string
SuccessfulComparisons int
}

func changerateAndFlattenHistory(commits []*model.Commit, breakingConfig *whatChangedModel.BreakingRulesConfig) (*historicalFlattenResult, error) {
reports := make([]*model.FlatReport, 0, len(commits))
var errs []error
var renderErrors []error
var skippedCommits []string
skippedSeen := make(map[string]struct{})
processedComparables := 0

for _, commit := range commits {
result, err := changerateCommit(commit, breakingConfig)
if commit == nil {
continue
}
comparable := commit.Document != nil && commit.OldDocument != nil
result, err := runChangerator(commit, breakingConfig)
if err != nil {
errs = append(errs, err)
emitCommitWarning(commit, err)
renderErrors = append(renderErrors, wrapCommitError(commit, err))
addSkippedCommitHash(&skippedCommits, skippedSeen, commit.Hash)
continue
}
if comparable {
processedComparables++
}
if result == nil {
continue
}
commit.Changes = result.DocChanges
reports = append(reports, FlattenReportWithParameterNames(createReport(commit), result.Changerator.ParameterNames))
result.Release()
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
if len(renderErrors) > 0 {
if len(reports) == 0 && processedComparables == 0 {
return nil, fmt.Errorf("all %d commits failed to render report data: %w", len(renderErrors), errors.Join(renderErrors...))
}
fmt.Fprintf(os.Stderr, "warning: %d commits failed to render report data\n", len(renderErrors))
}
return reports, nil
return &historicalFlattenResult{
Reports: reports,
SkippedCommits: skippedCommits,
SuccessfulComparisons: processedComparables,
}, nil
}

func runLeftRightReport(left, right string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) (*model.FlatReport, error) {
Expand All @@ -73,25 +100,11 @@ func runLeftRightReport(left, right string, opts summaryOpts, breakingConfig *wh
}

func runGitHistoryReport(gitPath, filePath string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) (*model.FlatHistoricalReport, error) {
commits, err := loadGitHistoryCommits(gitPath, filePath, opts, breakingConfig)
if err != nil {
return nil, err
}
if commits == nil {
return nil, nil
}
reports, err := changerateAndFlatten(commits, breakingConfig)
loaded, err := loadGitHistoryCommitsDetailed(gitPath, filePath, opts, breakingConfig)
if err != nil {
return nil, err
}

return &model.FlatHistoricalReport{
GitRepoPath: gitPath,
GitFilePath: filePath,
Filename: path.Base(filePath),
DateGenerated: time.Now().Format(time.RFC3339),
Reports: reports,
}, nil
return buildHistoricalReport(gitPath, filePath, loaded, breakingConfig)
}

func runGithubHistoryReport(rawURL string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) (*model.FlatHistoricalReport, error) {
Expand All @@ -104,22 +117,68 @@ func runGithubHistoryReport(rawURL string, opts summaryOpts, breakingConfig *wha
return nil, fmt.Errorf("error extracting github details: %w", err)
}

commits, err := loadGitHubCommits(rawURL, opts, breakingConfig)
loaded, err := loadGitHubCommitsDetailed(rawURL, opts, breakingConfig)
if err != nil {
return nil, err
}
reports, err := changerateAndFlatten(commits, breakingConfig)
return buildHistoricalReport(fmt.Sprintf("%s/%s", user, repo), filePath, loaded, breakingConfig)
}

func buildHistoricalReport(repoPath, filePath string, loaded *loadedHistoryResult,
breakingConfig *whatChangedModel.BreakingRulesConfig,
) (*model.FlatHistoricalReport, error) {
if loaded == nil {
return nil, nil
}
if len(loaded.Commits) == 0 && len(loaded.SkippedCommits) == 0 {
return nil, nil
}

history, err := changerateAndFlattenHistory(loaded.Commits, breakingConfig)
if err != nil {
return nil, err
}
skippedCommits := mergeSkippedCommitHashes(loaded.SkippedCommits, history.SkippedCommits)
if len(history.Reports) == 0 && history.SuccessfulComparisons == 0 && len(skippedCommits) > 0 {
return nil, fmt.Errorf("all %d candidate commits were skipped or failed to render", len(skippedCommits))
}

return &model.FlatHistoricalReport{
GitRepoPath: fmt.Sprintf("%s/%s", user, repo),
report := &model.FlatHistoricalReport{
GitRepoPath: repoPath,
GitFilePath: filePath,
Filename: path.Base(filePath),
DateGenerated: time.Now().Format(time.RFC3339),
Reports: reports,
}, nil
Reports: history.Reports,
}
if len(skippedCommits) > 0 {
report.MetaData = &model.HistoricalReportMetaData{
Partial: true,
SkippedCommits: skippedCommits,
}
}
return report, nil
}

func addSkippedCommitHash(skippedCommits *[]string, seen map[string]struct{}, hash string) {
if hash == "" {
return
}
if _, ok := seen[hash]; ok {
return
}
seen[hash] = struct{}{}
*skippedCommits = append(*skippedCommits, hash)
}

func mergeSkippedCommitHashes(groups ...[]string) []string {
var merged []string
seen := make(map[string]struct{})
for _, group := range groups {
for _, hash := range group {
addSkippedCommitHash(&merged, seen, hash)
}
}
return merged
}

func printReportJSON(v any) error {
Expand Down
Loading