1// SPDX-License-Identifier: Apache-2.0
2
3package schema_test
4
5import (
6 "context"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "cuelang.org/go/cue"
14 "cuelang.org/go/cue/load"
15 "cuelang.org/go/mod/modconfig"
16 "cuelang.org/go/mod/modregistry"
17 "cuelang.org/go/mod/module"
18 "golang.org/x/mod/semver"
19)
20
21const modulePath = "github.com/gemaraproj/gemara"
22
23func TestNoBreakingChanges(t *testing.T) {
24 ctx := context.Background()
25
26 resolver, err := modconfig.NewResolver(&modconfig.Config{
27 CUERegistry: modconfig.DefaultRegistry,
28 })
29 if err != nil {
30 t.Fatalf("failed to create resolver: %v", err)
31 }
32 regClient := modregistry.NewClientWithResolver(resolver)
33
34 includePrerelease := os.Getenv("GEMARA_COMPAT_PRERELEASE") == "true"
35
36 latestVer, err := latestVersion(ctx, regClient, modulePath, includePrerelease)
37 if err != nil {
38 t.Logf("no suitable release found | skipping compatibility check: %v", err)
39 t.Skip()
40 }
41 t.Logf("comparing against released version: %s", latestVer)
42
43 reg, err := modconfig.NewRegistry(&modconfig.Config{
44 CUERegistry: modconfig.DefaultRegistry,
45 })
46 if err != nil {
47 t.Fatalf("failed to create registry: %v", err)
48 }
49
50 oldSchema, err := loadModuleFromRegistry(reg, latestVer)
51 if err != nil {
52 t.Fatalf("failed to load released module: %v", err)
53 }
54
55 schemaDir, err := filepath.Abs("..")
56 if err != nil {
57 t.Fatalf("failed to resolve schema directory: %v", err)
58 }
59
60 localSchema, err := loadLocalSchemaRelaxed(schemaDir)
61 if err != nil {
62 t.Fatalf("failed to load local schema: %v", err)
63 }
64
65 stableDefs, err := collectStableDefs(schemaDir)
66 if err != nil {
67 t.Fatalf("failed to collect stable definitions: %v", err)
68 }
69 t.Logf("found %d stable definitions to check", len(stableDefs))
70
71 for _, defPath := range stableDefs {
72 defPath := defPath
73 t.Run(defPath, func(t *testing.T) {
74 newDef := localSchema.LookupPath(cue.ParsePath(defPath))
75 if newDef.Err() != nil {
76 t.Fatalf("new schema: lookup %s: %v", defPath, newDef.Err())
77 }
78
79 oldDef := oldSchema.LookupPath(cue.ParsePath(defPath))
80 if oldDef.Err() != nil {
81 t.Logf("definition %s not found in released version (new addition)", defPath)
82 return
83 }
84
85 if err := newDef.Subsume(oldDef, cue.Raw(), cue.Schema()); err != nil {
86 t.Errorf("breaking change detected in %s:\n%v", defPath, err)
87 }
88 })
89 }
90}
91
92// loadLocalSchemaRelaxed loads the local CUE schema with builtin validators
93// and hidden constraint fields relaxed so that CUE's Subsume does not produce
94// false positives when comparing values from different load contexts
95// (filesystem vs OCI registry).
96func loadLocalSchemaRelaxed(schemaDir string) (cue.Value, error) {
97 entries, err := os.ReadDir(schemaDir)
98 if err != nil {
99 return cue.Value{}, fmt.Errorf("read schema dir: %w", err)
100 }
101
102 overlay := make(map[string]load.Source)
103 for _, entry := range entries {
104 if !strings.HasSuffix(entry.Name(), ".cue") {
105 continue
106 }
107 absPath := filepath.Join(schemaDir, entry.Name())
108 original, err := os.ReadFile(absPath)
109 if err != nil {
110 return cue.Value{}, fmt.Errorf("read %s: %w", entry.Name(), err)
111 }
112 relaxed := relaxForSubsume(string(original))
113 if relaxed != string(original) {
114 overlay[absPath] = load.FromString(relaxed)
115 }
116 }
117
118 cfg := &load.Config{
119 Dir: schemaDir,
120 Overlay: overlay,
121 }
122 instances := load.Instances([]string{"."}, cfg)
123 if len(instances) == 0 {
124 return cue.Value{}, fmt.Errorf("no CUE instances returned")
125 }
126 if err := instances[0].Err; err != nil {
127 return cue.Value{}, fmt.Errorf("loading local schema: %w", err)
128 }
129 val := schemaCtx.BuildInstance(instances[0])
130 if err := val.Err(); err != nil {
131 return cue.Value{}, fmt.Errorf("building local schema: %w", err)
132 }
133 return val, nil
134}
135
136// relaxForSubsume strips builtin validators and hidden constraint fields
137// that cause cross-context Subsume false positives. The time.Format validator
138// and list.Contains-based group validation both fail when compared across
139// independently loaded CUE instances.
140func relaxForSubsume(content string) string {
141 crossContextNoise := []string{
142 "_validGroupIds",
143 "_groupValidation",
144 "_validApplicabilityIds",
145 "_applicabilityValidation",
146 "// Unify the valid ID list with a list.Contains constraint",
147 }
148
149 var lines []string
150 for _, line := range strings.Split(content, "\n") {
151 skip := false
152 for _, p := range crossContextNoise {
153 if strings.Contains(line, p) {
154 skip = true
155 break
156 }
157 }
158 if !skip {
159 lines = append(lines, line)
160 }
161 }
162 result := strings.Join(lines, "\n")
163
164 result = strings.Replace(result,
165 `#Datetime: time.Format("2006-01-02T15:04:05Z07:00")`,
166 `#Datetime: string`, 1)
167
168 if !strings.Contains(result, "time.") {
169 result = strings.Replace(result, `import "time"`, "", 1)
170 }
171 if !strings.Contains(result, "list.") {
172 result = strings.Replace(result, `import "list"`, "", 1)
173 }
174
175 return result
176}
177
178func collectStableDefs(schemaDir string) ([]string, error) {
179 var stableDefs []string
180
181 entries, err := os.ReadDir(schemaDir)
182 if err != nil {
183 return nil, fmt.Errorf("read schema dir: %w", err)
184 }
185
186 for _, entry := range entries {
187 if !strings.HasSuffix(entry.Name(), ".cue") {
188 continue
189 }
190 data, err := os.ReadFile(filepath.Join(schemaDir, entry.Name()))
191 if err != nil {
192 return nil, fmt.Errorf("read %s: %w", entry.Name(), err)
193 }
194 content := string(data)
195 if !strings.Contains(content, `@status("stable")`) {
196 continue
197 }
198 for _, line := range strings.Split(content, "\n") {
199 line = strings.TrimSpace(line)
200 if strings.HasPrefix(line, "#") && strings.Contains(line, ":") {
201 def := strings.TrimSpace(strings.SplitN(line, ":", 2)[0])
202 stableDefs = append(stableDefs, def)
203 }
204 }
205 }
206 return stableDefs, nil
207}
208
209func latestVersion(ctx context.Context, client *modregistry.Client, modPath string, includePrerelease bool) (module.Version, error) {
210 versions, err := client.ModuleVersions(ctx, modPath+"@v1")
211 if err != nil {
212 return module.Version{}, fmt.Errorf("listing versions for %s: %w", modPath, err)
213 }
214 for i := len(versions) - 1; i >= 0; i-- {
215 v := versions[i]
216 if includePrerelease || semver.Prerelease(v) == "" {
217 return module.NewVersion(modPath, v)
218 }
219 }
220 if includePrerelease {
221 return module.Version{}, fmt.Errorf("no versions found for %s", modPath)
222 }
223 return module.Version{}, fmt.Errorf("no stable release found for %s (set GEMARA_COMPAT_PRERELEASE=true to include pre-releases)", modPath)
224}
225
226func loadModuleFromRegistry(reg modconfig.Registry, ver module.Version) (cue.Value, error) {
227 instances := load.Instances([]string{ver.String()}, &load.Config{
228 Registry: reg,
229 })
230 if len(instances) == 0 {
231 return cue.Value{}, fmt.Errorf("no CUE instances returned for %v", ver)
232 }
233 if err := instances[0].Err; err != nil {
234 return cue.Value{}, fmt.Errorf("loading module %v: %w", ver, err)
235 }
236 val := schemaCtx.BuildInstance(instances[0])
237 if err := val.Err(); err != nil {
238 return cue.Value{}, fmt.Errorf("building schema for %v: %w", ver, err)
239 }
240 return val, nil
241}