-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontract_wire_shape_test.go
More file actions
561 lines (527 loc) · 17.2 KB
/
Copy pathcontract_wire_shape_test.go
File metadata and controls
561 lines (527 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
// Wire-shape contract test: OpenAPI spec ↔ Go SDK struct tags.
//
// This test catches camelCase / snake_case drift and missing-field drift
// between the authoritative OpenAPI specs and the SDK's exported structs.
// It is the Go arm of QF-15 (see axonflow-enterprise#1699); the Python
// arm lives in axonflow-sdk-python's tests/test_wire_shape.py.
//
// Data flow:
// - Load every *.yaml under AXONFLOW_OPENAPI_SPECS_DIR (set by CI
// to testdata/openapi, a derived snapshot). Collect every schema that has
// concrete `properties`.
// - Walk this package's source files via go/parser, find every
// exported struct, compute its wire-shape field names (the `json`
// tag when set, otherwise the Go field name).
// - For every struct whose type name matches a schema name, diff the
// sorted property-name sets and fail on drift that is not covered
// by the baseline in testdata/wire_shape_baseline.json.
//
// The test gate is opt-in via AXONFLOW_OPENAPI_SPECS_DIR: without the
// env var, all wire-shape tests skip cleanly, so `go test ./...` on a
// dev box with no specs checkout continues to work. The dedicated CI
// job sets the env var and runs only TestWireShape* via -run.
//
// All schema/struct/yaml logic lives in internal/wireshape so this test
// and scripts/refresh_wire_shape_baseline share a single source of
// truth. See internal/wireshape/wireshape.go.
//
// To regenerate the baseline (after a legitimate burn-down or platform
// spec change); the pinned commit comes from the snapshot's headers:
//
// go run ./scripts/refresh_wire_shape_baseline testdata/openapi
//
// (flags BEFORE the positional specs dir - the flag package stops
// parsing at the first non-flag argument.)
package axonflow
import (
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"sort"
"strings"
"testing"
"github.com/getaxonflow/axonflow-sdk-go/v9/internal/wireshape"
)
// ExcludedTypes names struct types that legitimately do not participate
// in the wire contract even if their name collides with an OpenAPI
// schema. Each entry needs a one-line reason. The canonical map lives
// in internal/wireshape (single source of truth shared with the
// baseline refresher, and consulted by DiscoverSDKTypes BEFORE its
// embedded-field check); this alias keeps the gate reading the same
// data.
var ExcludedTypes = wireshape.ExcludedTypes
const baselinePath = "testdata/wire_shape_baseline.json"
// loadBaseline returns a zero baseline when the file is missing so that
// bootstrap runs (before the first refresh) produce actionable output
// instead of failing in fixture setup.
func loadBaseline(t *testing.T) wireshape.Baseline {
t.Helper()
data, err := os.ReadFile(baselinePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return wireshape.NewEmptyBaseline()
}
t.Fatalf("read baseline %s: %v", baselinePath, err)
}
var b wireshape.Baseline
if err := json.Unmarshal(data, &b); err != nil {
t.Fatalf("parse baseline %s: %v", baselinePath, err)
}
if b.CrossSpecDuplicates == nil {
b.CrossSpecDuplicates = map[string]map[string][]string{}
}
if b.PerTypeDrift == nil {
b.PerTypeDrift = map[string]wireshape.DriftEntry{}
}
return b
}
// specsDir returns the env-var path if it points at an existing
// directory, or "" if the gate should skip.
func specsDir() string {
p := os.Getenv("AXONFLOW_OPENAPI_SPECS_DIR")
if p == "" {
return ""
}
st, err := os.Stat(p)
if err != nil || !st.IsDir() {
return ""
}
return p
}
func TestWireShapeSpecsDirIsPopulated(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
schemas, _, _, err := wireshape.LoadSchemas(dir)
if err != nil {
t.Fatalf("load specs: %v", err)
}
if len(schemas) == 0 {
t.Fatalf("no schemas with properties loaded from %s", dir)
}
}
// TestWireShapeSnapshotMatchesThePin proves the specs the gate reads are the
// revision the baseline pins: a generated snapshot's headers name the
// platform commit it was derived from, and that must equal
// openapi_specs_sha, or every other TestWireShape* compares the SDK against
// specs the baseline does not describe.
func TestWireShapeSnapshotMatchesThePin(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
commit, err := wireshape.SnapshotSourceCommit(dir)
if err != nil {
t.Fatalf("read the snapshot's pin: %v", err)
}
if commit == "" {
t.Skipf("%s is not a generated snapshot; its pin cannot be checked", dir)
}
if pinned := loadBaseline(t).OpenAPISpecsSHA; commit != pinned {
t.Fatalf("the snapshot in %s was derived at platform commit %s, but %s pins %q; "+
"regenerate the baseline: go run ./scripts/refresh_wire_shape_baseline testdata/openapi",
dir, commit, baselinePath, pinned)
}
}
func TestWireShapeNoNewCrossSpecDivergence(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
_, observed, _, err := wireshape.LoadSchemas(dir)
if err != nil {
t.Fatalf("load specs: %v", err)
}
baseline := loadBaseline(t)
type problem struct {
name string
body string
}
var problems []problem
for name, decls := range observed {
expected, ok := baseline.CrossSpecDuplicates[name]
if !ok {
var b strings.Builder
fmt.Fprintf(&b, " %s: NEW cross-spec divergence (not in baseline).\n", name)
for _, spec := range sortedKeys(decls) {
fmt.Fprintf(&b, " %s: %v\n", spec, decls[spec])
}
problems = append(problems, problem{name, b.String()})
continue
}
if !equalPerSpecShapes(expected, decls) {
var b strings.Builder
fmt.Fprintf(&b, " %s: divergence drifted from baseline.\n", name)
allSpecs := map[string]struct{}{}
for s := range expected {
allSpecs[s] = struct{}{}
}
for s := range decls {
allSpecs[s] = struct{}{}
}
specList := []string{}
for s := range allSpecs {
specList = append(specList, s)
}
sort.Strings(specList)
for _, spec := range specList {
exp := expected[spec]
obs := decls[spec]
if reflect.DeepEqual(exp, obs) {
continue
}
fmt.Fprintf(&b, " %s:\n baseline: %v\n observed: %v\n", spec, exp, obs)
}
problems = append(problems, problem{name, b.String()})
}
}
if len(problems) == 0 {
return
}
sort.Slice(problems, func(i, j int) bool { return problems[i].name < problems[j].name })
var out strings.Builder
out.WriteString("\nCross-spec schema divergence gate failed:\n\n")
for _, p := range problems {
out.WriteString(p.body)
}
out.WriteString("\nFix: reconcile in the axonflow-enterprise specs (rename one, " +
"or merge into a shared supertype). If the divergence is intentional " +
"and must stand, regenerate testdata/wire_shape_baseline.json via " +
"`go run ./scripts/refresh_wire_shape_baseline ...`.\n")
t.Fatal(out.String())
}
func TestWireShapeNoNewIntraFileDuplicates(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
_, _, observed, err := wireshape.LoadSchemas(dir)
if err != nil {
t.Fatalf("load specs: %v", err)
}
baseline := loadBaseline(t)
allowed := baseline.IntraFileDuplicates
type problem struct{ body string }
var problems []problem
// Every observed intra-file duplicate must match the baseline entry
// exactly — both file AND count. A new duplicate OR a count change
// (e.g. PolicyMatch going from 2 to 3 copies) fails the gate.
for file, schemas := range observed {
for schemaName, count := range schemas {
allowedCount, ok := allowed[file][schemaName]
if ok && allowedCount == count {
continue
}
problems = append(problems, problem{fmt.Sprintf(
" %s: schema '%s' declared %d times (baseline says %d).",
file, schemaName, count, allowedCount,
)})
}
}
// A baselined duplicate that no longer appears also fails — force
// the baseline author to remove it deliberately, the same contract
// as the cross-spec gate.
for file, schemas := range allowed {
for schemaName := range schemas {
if _, ok := observed[file][schemaName]; !ok {
problems = append(problems, problem{fmt.Sprintf(
" %s: baselined duplicate '%s' no longer observed — remove from baseline.intra_file_duplicates.",
file, schemaName,
)})
}
}
}
if len(problems) == 0 {
return
}
sort.Slice(problems, func(i, j int) bool { return problems[i].body < problems[j].body })
var b strings.Builder
b.WriteString("\nIntra-file schema duplicate gate failed:\n\n")
for _, p := range problems {
b.WriteString(p.body)
b.WriteString("\n")
}
b.WriteString("\nFix: remove the duplicate declaration in the OpenAPI spec. " +
"A schema declared twice in one file leaves the contract ambiguous — " +
"only the last declaration is authoritative at parse time, so readers " +
"of the first disagree with readers of the second. If the duplicate " +
"is intentional and must stand, regenerate " +
"testdata/wire_shape_baseline.json to acknowledge it.\n")
t.Fatal(b.String())
}
func TestWireShapeNoNewSDKVsSpecDrift(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
merged, _, _, err := wireshape.LoadSchemas(dir)
if err != nil {
t.Fatalf("load specs: %v", err)
}
sdk, err := wireshape.DiscoverSDKTypes(".")
if err != nil {
t.Fatalf("discover SDK types: %v", err)
}
baseline := loadBaseline(t)
matched := 0
type drift struct {
name string
newSDKOnly []string
newSpecOnly []string
baseSDKOnly []string
baseSpecOnly []string
}
var newDrift []drift
for name, sdkFields := range sdk {
if _, excluded := ExcludedTypes[name]; excluded {
continue
}
specFields, ok := merged[name]
if !ok {
continue
}
matched++
sdkOnly := wireshape.Difference(sdkFields, specFields)
specOnly := wireshape.Difference(specFields, sdkFields)
allowed, ok := baseline.PerTypeDrift[name]
expectedSDK := map[string]struct{}{}
expectedSpec := map[string]struct{}{}
if ok {
for _, f := range allowed.SDKOnly {
expectedSDK[f] = struct{}{}
}
for _, f := range allowed.SpecOnly {
expectedSpec[f] = struct{}{}
}
}
newSDK := subtractSet(sdkOnly, expectedSDK)
newSpec := subtractSet(specOnly, expectedSpec)
if len(newSDK) == 0 && len(newSpec) == 0 {
continue
}
newDrift = append(newDrift, drift{
name: name,
newSDKOnly: newSDK,
newSpecOnly: newSpec,
baseSDKOnly: subtractSet(sdkOnly, toSet(newSDK)),
baseSpecOnly: subtractSet(specOnly, toSet(newSpec)),
})
}
if matched == 0 {
t.Fatal("No SDK struct matched any OpenAPI schema by name — check discovery.")
}
if len(newDrift) == 0 {
return
}
sort.Slice(newDrift, func(i, j int) bool { return newDrift[i].name < newDrift[j].name })
var b strings.Builder
b.WriteString("\nNEW wire-shape drift detected (not covered by baseline):\n\n")
for _, d := range newDrift {
fmt.Fprintf(&b, " %s:\n", d.name)
if len(d.newSDKOnly) > 0 {
fmt.Fprintf(&b, " NEW, only in SDK struct: %v\n", d.newSDKOnly)
}
if len(d.newSpecOnly) > 0 {
fmt.Fprintf(&b, " NEW, only in OpenAPI: %v\n", d.newSpecOnly)
}
if len(d.baseSDKOnly) > 0 {
fmt.Fprintf(&b, " (baseline, only in SDK): %v\n", d.baseSDKOnly)
}
if len(d.baseSpecOnly) > 0 {
fmt.Fprintf(&b, " (baseline, only in spec): %v\n", d.baseSpecOnly)
}
}
b.WriteString("\nFix: align the json tag (or add one) to match the OpenAPI " +
"property name, OR update the spec if the SDK is the source of truth. " +
"Do not widen the baseline to hide drift without a tracking issue.\n")
t.Fatal(b.String())
}
func TestWireShapeRegisteredTypesStillMap(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
merged, _, _, err := wireshape.LoadSchemas(dir)
if err != nil {
t.Fatalf("load specs: %v", err)
}
sdk, err := wireshape.DiscoverSDKTypes(".")
if err != nil {
t.Fatalf("discover SDK types: %v", err)
}
baseline := loadBaseline(t)
if len(baseline.RegisteredTypes) == 0 {
t.Skip("baseline has no registered_types list; rename-escape guard disabled until baseline is regenerated.")
}
var missingSDK, missingSpec []string
for _, name := range baseline.RegisteredTypes {
if _, ok := sdk[name]; !ok {
missingSDK = append(missingSDK, name)
}
if _, ok := merged[name]; !ok {
missingSpec = append(missingSpec, name)
}
}
if len(missingSDK) == 0 && len(missingSpec) == 0 {
return
}
var b strings.Builder
b.WriteString("\nRegistered-type mapping broken — rename-escape guard fired:\n\n")
if len(missingSDK) > 0 {
fmt.Fprintf(&b, " No matching SDK struct for: %v\n", missingSDK)
}
if len(missingSpec) > 0 {
fmt.Fprintf(&b, " No matching OpenAPI schema for: %v\n", missingSpec)
}
b.WriteString("\nFix: either revert the rename, do it on both sides, or update " +
"testdata/wire_shape_baseline.json::registered_types (and mirror the " +
"rename in baseline.per_type_drift entries).\n")
t.Fatal(b.String())
}
// TestWireShapeBaselineIsNotStale is the burn-down ratchet: a
// per_type_drift allowance that no longer matches observed drift FAILS
// the gate instead of merely logging. A stale allowance is not inert -
// it pre-authorizes future drift of that field name, so a field could
// later be (re)introduced on either side without any gate going red.
//
// Four stale classes fail, with distinct messages (see
// wireshape.StaleBaselineProblems for the classification and its table
// test): burned-down fields, dead/phantom field allowances, vanished
// types whose entry carries ANY field allowance, and empty entries
// whose type maps on both sides. The ONLY tolerated vanished-type
// shape is an empty entry carrying only a curated _note - the
// documented pattern for types that must outlive their spec
// declaration until the next major; those are logged, not failed.
func TestWireShapeBaselineIsNotStale(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
merged, _, _, err := wireshape.LoadSchemas(dir)
if err != nil {
t.Fatalf("load specs: %v", err)
}
sdk, err := wireshape.DiscoverSDKTypes(".")
if err != nil {
t.Fatalf("discover SDK types: %v", err)
}
baseline := loadBaseline(t)
// The classification logic lives in internal/wireshape and is
// table-tested there (TestStaleBaselineProblems) - a ratchet whose
// only exercise is its own green run fails open on its own
// mutations.
problems, vanishedAcks := wireshape.StaleBaselineProblems(baseline.PerTypeDrift, sdk, merged)
for _, name := range vanishedAcks {
t.Logf(" %s: <type or schema no longer exists at this pin; empty entry tolerated for its curated _note acknowledgment>", name)
}
if len(problems) == 0 {
return
}
var b strings.Builder
b.WriteString("\nStale baseline allowances detected (burn-down ratchet):\n\n")
for _, p := range problems {
b.WriteString(p)
b.WriteString("\n")
}
b.WriteString("\nFix: shrink the listed per_type_drift entries in " + baselinePath + ". " +
"A stale allowance silently pre-authorizes future drift of that field name.\n")
t.Fatal(b.String())
}
func TestWireShapeUnmappedTypesAreTracked(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
merged, _, _, err := wireshape.LoadSchemas(dir)
if err != nil {
t.Fatalf("load specs: %v", err)
}
sdk, err := wireshape.DiscoverSDKTypes(".")
if err != nil {
t.Fatalf("discover SDK types: %v", err)
}
unmapped := []string{}
for name := range sdk {
if _, ok := merged[name]; ok {
continue
}
if _, ex := ExcludedTypes[name]; ex {
continue
}
unmapped = append(unmapped, name)
}
sort.Strings(unmapped)
t.Logf("%d SDK struct(s) have no matching OpenAPI schema (internal / client-side):", len(unmapped))
for _, n := range unmapped {
t.Logf(" - %s", n)
}
}
func TestWireShapeUnmappedSchemasAreTracked(t *testing.T) {
dir := specsDir()
if dir == "" {
t.Skip("AXONFLOW_OPENAPI_SPECS_DIR not set; wire-shape tests skipped")
}
merged, _, _, err := wireshape.LoadSchemas(dir)
if err != nil {
t.Fatalf("load specs: %v", err)
}
sdk, err := wireshape.DiscoverSDKTypes(".")
if err != nil {
t.Fatalf("discover SDK types: %v", err)
}
unmapped := []string{}
for name := range merged {
if _, ok := sdk[name]; !ok {
unmapped = append(unmapped, name)
}
}
sort.Strings(unmapped)
t.Logf("%d OpenAPI schema(s) have no matching Go SDK struct:", len(unmapped))
for _, n := range unmapped {
t.Logf(" - %s", n)
}
}
// ---- small test-local helpers ----
func toSet(s []string) map[string]struct{} {
out := make(map[string]struct{}, len(s))
for _, v := range s {
out[v] = struct{}{}
}
return out
}
func subtractSet(a []string, b map[string]struct{}) []string {
out := []string{}
for _, v := range a {
if _, ok := b[v]; !ok {
out = append(out, v)
}
}
sort.Strings(out)
return out
}
func equalPerSpecShapes(a, b map[string][]string) bool {
if len(a) != len(b) {
return false
}
for k, av := range a {
bv, ok := b[k]
if !ok {
return false
}
if !reflect.DeepEqual(av, bv) {
return false
}
}
return true
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}