Skip to content

Commit ba4e99e

Browse files
committed
Address issue #140
Reports are rendered deterministically now.
1 parent 578cdeb commit ba4e99e

6 files changed

Lines changed: 251 additions & 8 deletions

File tree

cmd/flag_surface_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ func TestCanonicalCommandLocalFlags(t *testing.T) {
3030
}, flagNames(GetSummaryCommand()))
3131

3232
assert.Equal(t, map[string]bool{
33-
"no-color": true,
34-
"roger-mode": true,
35-
"tektronix": true,
33+
"no-color": true,
34+
"roger-mode": true,
35+
"reproducible": true,
36+
"tektronix": true,
3637
}, flagNames(GetReportCommand()))
3738

3839
assert.Equal(t, map[string]bool{

cmd/flatten_report.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package cmd
55

66
import (
7+
"cmp"
8+
"sort"
79
"strconv"
810
"strings"
911
"time"
@@ -44,6 +46,7 @@ func flattenReport(report *model.Report, parameterNames map[string]string) *mode
4446

4547
changes = append(changes, &hashedChange)
4648
}
49+
sortFlatReportChanges(changes)
4750
flatReport.Changes = changes
4851

4952
// 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 {
120123
}
121124
return rawPath
122125
}
126+
127+
func sortFlatReportChanges(changes []*model.HashedChange) {
128+
sort.SliceStable(changes, func(i, j int) bool {
129+
return compareHashedChanges(changes[i], changes[j]) < 0
130+
})
131+
}
132+
133+
func compareHashedChanges(left, right *model.HashedChange) int {
134+
if diff := cmp.Compare(changePath(left), changePath(right)); diff != 0 {
135+
return diff
136+
}
137+
if diff := cmp.Compare(changeRawPath(left), changeRawPath(right)); diff != 0 {
138+
return diff
139+
}
140+
if diff := cmp.Compare(changeType(left), changeType(right)); diff != 0 {
141+
return diff
142+
}
143+
if diff := cmp.Compare(changeProperty(left), changeProperty(right)); diff != 0 {
144+
return diff
145+
}
146+
if diff := cmp.Compare(changeKind(left), changeKind(right)); diff != 0 {
147+
return diff
148+
}
149+
if diff := cmp.Compare(changeBreaking(left), changeBreaking(right)); diff != 0 {
150+
return diff
151+
}
152+
if diff := cmp.Compare(changeOriginal(left), changeOriginal(right)); diff != 0 {
153+
return diff
154+
}
155+
if diff := cmp.Compare(changeNew(left), changeNew(right)); diff != 0 {
156+
return diff
157+
}
158+
if diff := cmp.Compare(changeOriginalEncoded(left), changeOriginalEncoded(right)); diff != 0 {
159+
return diff
160+
}
161+
if diff := cmp.Compare(changeNewEncoded(left), changeNewEncoded(right)); diff != 0 {
162+
return diff
163+
}
164+
if diff := cmp.Compare(changeReference(left), changeReference(right)); diff != 0 {
165+
return diff
166+
}
167+
return cmp.Compare(changeHash(left), changeHash(right))
168+
}
169+
170+
func changePath(change *model.HashedChange) string {
171+
if change == nil || change.Change == nil {
172+
return ""
173+
}
174+
return change.Path
175+
}
176+
177+
func changeRawPath(change *model.HashedChange) string {
178+
if change == nil {
179+
return ""
180+
}
181+
return change.RawPath
182+
}
183+
184+
func changeType(change *model.HashedChange) string {
185+
if change == nil || change.Change == nil {
186+
return ""
187+
}
188+
return change.Type
189+
}
190+
191+
func changeProperty(change *model.HashedChange) string {
192+
if change == nil || change.Change == nil {
193+
return ""
194+
}
195+
return change.Property
196+
}
197+
198+
func changeKind(change *model.HashedChange) int {
199+
if change == nil || change.Change == nil {
200+
return 0
201+
}
202+
return change.ChangeType
203+
}
204+
205+
func changeBreaking(change *model.HashedChange) int {
206+
if change == nil || change.Change == nil || !change.Breaking {
207+
return 0
208+
}
209+
return 1
210+
}
211+
212+
func changeOriginal(change *model.HashedChange) string {
213+
if change == nil || change.Change == nil {
214+
return ""
215+
}
216+
return change.Original
217+
}
218+
219+
func changeNew(change *model.HashedChange) string {
220+
if change == nil || change.Change == nil {
221+
return ""
222+
}
223+
return change.New
224+
}
225+
226+
func changeOriginalEncoded(change *model.HashedChange) string {
227+
if change == nil || change.Change == nil {
228+
return ""
229+
}
230+
return change.OriginalEncoded
231+
}
232+
233+
func changeNewEncoded(change *model.HashedChange) string {
234+
if change == nil || change.Change == nil {
235+
return ""
236+
}
237+
return change.NewEncoded
238+
}
239+
240+
func changeReference(change *model.HashedChange) string {
241+
if change == nil || change.Change == nil {
242+
return ""
243+
}
244+
return change.Reference
245+
}
246+
247+
func changeHash(change *model.HashedChange) string {
248+
if change == nil {
249+
return ""
250+
}
251+
return change.ChangeHash
252+
}

cmd/flatten_report_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88

99
wcModel "github.com/pb33f/libopenapi/what-changed/model"
10+
openapiModel "github.com/pb33f/openapi-changes/model"
1011
"github.com/stretchr/testify/assert"
1112
)
1213

@@ -176,3 +177,46 @@ func TestRawPathIfChanged_SamePaths(t *testing.T) {
176177
func TestRawPathIfChanged_DifferentPaths(t *testing.T) {
177178
assert.Equal(t, "$.parameters[0]", rawPathIfChanged("$.parameters[0]", "$.parameters['petId']"))
178179
}
180+
181+
func TestFlattenReport_SortsChangesDeterministically(t *testing.T) {
182+
report := &openapiModel.Report{
183+
Commit: &openapiModel.Commit{
184+
Changes: &wcModel.DocumentChanges{
185+
PropertyChanges: wcModel.NewPropertyChanges([]*wcModel.Change{
186+
{
187+
Context: &wcModel.ChangeContext{},
188+
ChangeType: wcModel.PropertyAdded,
189+
Path: "$.paths['/pets'].post",
190+
Property: "summary",
191+
Type: "operation",
192+
New: "create a pet",
193+
},
194+
{
195+
Context: &wcModel.ChangeContext{},
196+
ChangeType: wcModel.Modified,
197+
Path: "$.info",
198+
Property: "title",
199+
Type: "info",
200+
Original: "zeta",
201+
New: "alpha",
202+
},
203+
{
204+
Context: &wcModel.ChangeContext{},
205+
ChangeType: wcModel.PropertyRemoved,
206+
Path: "$.paths['/pets'].get",
207+
Property: "description",
208+
Type: "operation",
209+
Original: "list pets",
210+
},
211+
}),
212+
},
213+
},
214+
}
215+
216+
flat := FlattenReport(report)
217+
218+
assert.Len(t, flat.Changes, 3)
219+
assert.Equal(t, "$.info", flat.Changes[0].Path)
220+
assert.Equal(t, "$.paths['/pets'].get", flat.Changes[1].Path)
221+
assert.Equal(t, "$.paths['/pets'].post", flat.Changes[2].Path)
222+
}

cmd/report.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ func GetReportCommand() *cobra.Command {
146146
if err != nil {
147147
return err
148148
}
149+
reproducible, err := cmd.Flags().GetBool("reproducible")
150+
if err != nil {
151+
return err
152+
}
149153

150154
if len(args) == 0 {
151155
maybePrintBanner(cmd, opts.palette)
@@ -174,15 +178,18 @@ func GetReportCommand() *cobra.Command {
174178
if reportErr != nil {
175179
return reportErr
176180
}
181+
if reproducible {
182+
makeReportOutputReproducible(flat)
183+
}
177184
return printReportJSON(flat)
178185
}
179186

180187
if !isHTTPURL(args[0]) {
181188
if _, _, ok := parseGitRef(args[0]); ok {
182-
return printReportJSONOrNoChanges(args, opts, breakingConfig)
189+
return printReportJSONOrNoChanges(args, opts, breakingConfig, reproducible)
183190
}
184191
if _, _, ok := parseGitRef(args[1]); ok {
185-
return printReportJSONOrNoChanges(args, opts, breakingConfig)
192+
return printReportJSONOrNoChanges(args, opts, breakingConfig, reproducible)
186193
}
187194
f, statErr := os.Stat(args[0])
188195
if statErr == nil && f.IsDir() {
@@ -194,18 +201,22 @@ func GetReportCommand() *cobra.Command {
194201
printNoChangesJSON()
195202
return nil
196203
}
204+
if reproducible {
205+
makeReportOutputReproducible(flat)
206+
}
197207
return printReportJSON(flat)
198208
}
199209
}
200210

201-
return printReportJSONOrNoChanges(args, opts, breakingConfig)
211+
return printReportJSONOrNoChanges(args, opts, breakingConfig, reproducible)
202212
},
203213
}
204214
addTerminalThemeFlags(cmd)
215+
cmd.Flags().Bool("reproducible", false, "Omit generated timestamps so report JSON is stable across runs")
205216
return cmd
206217
}
207218

208-
func printReportJSONOrNoChanges(args []string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig) error {
219+
func printReportJSONOrNoChanges(args []string, opts summaryOpts, breakingConfig *whatChangedModel.BreakingRulesConfig, reproducible bool) error {
209220
flat, reportErr := runLeftRightReport(args[0], args[1], opts, breakingConfig)
210221
if reportErr != nil {
211222
return reportErr
@@ -214,5 +225,37 @@ func printReportJSONOrNoChanges(args []string, opts summaryOpts, breakingConfig
214225
printNoChangesJSON()
215226
return nil
216227
}
228+
if reproducible {
229+
makeReportOutputReproducible(flat)
230+
}
217231
return printReportJSON(flat)
218232
}
233+
234+
func makeReportOutputReproducible(report any) {
235+
switch typed := report.(type) {
236+
case *model.FlatReport:
237+
if typed != nil {
238+
makeFlatReportReproducible(typed)
239+
}
240+
case *model.FlatHistoricalReport:
241+
if typed == nil {
242+
return
243+
}
244+
typed.DateGenerated = ""
245+
for _, item := range typed.Reports {
246+
makeFlatReportReproducible(item)
247+
}
248+
}
249+
}
250+
251+
func makeFlatReportReproducible(report *model.FlatReport) {
252+
if report == nil {
253+
return
254+
}
255+
report.DateGenerated = ""
256+
for _, change := range report.Changes {
257+
if change != nil {
258+
change.RawPath = ""
259+
}
260+
}
261+
}

cmd/report_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,31 @@ func TestRunLeftRightReport_OmitsCommitDetailsInJSON(t *testing.T) {
165165
assert.NotContains(t, string(encoded), "commitDetails")
166166
}
167167

168+
func TestReportCommand_ReproducibleOutputIsStableAcrossRuns(t *testing.T) {
169+
args := []string{
170+
"--no-logo",
171+
"--no-color",
172+
"--reproducible",
173+
"../sample-specs/petstorev3-original.json",
174+
"../sample-specs/petstorev3.json",
175+
}
176+
177+
runOnce := func() string {
178+
cmd := testRootCmd(GetReportCommand(), args...)
179+
return captureStdout(t, func() {
180+
require.NoError(t, cmd.Execute())
181+
})
182+
}
183+
184+
first := runOnce()
185+
second := runOnce()
186+
187+
assert.Equal(t, first, second)
188+
assert.NotContains(t, first, `"dateGenerated"`)
189+
assert.NotContains(t, first, `"commitDetails"`)
190+
assert.NotContains(t, first, `"rawPath"`)
191+
}
192+
168193
func TestRunLeftRightReport_GitRefExplodedSpecIncludesSiblingChanges(t *testing.T) {
169194
repoDir, _ := createExplodedGitSpecRepo(t)
170195
chdirForTest(t, repoDir)

model/report.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,6 @@ type FlatHistoricalReport struct {
8787
GitRepoPath string `json:"gitRepoPath"`
8888
GitFilePath string `json:"gitFilePath"`
8989
Filename string `json:"filename"`
90-
DateGenerated string `json:"dateGenerated"`
90+
DateGenerated string `json:"dateGenerated,omitempty"`
9191
Reports []*FlatReport `json:"reports" `
9292
}

0 commit comments

Comments
 (0)