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 = stripHiddenDefBlocks(result)
165
166 result = strings.Replace(result,
167 `#Datetime: time.Format("2006-01-02T15:04:05Z07:00")`,
168 `#Datetime: string`, 1)
169
170 if !strings.Contains(result, "time.") {
171 result = strings.Replace(result, `import "time"`, "", 1)
172 }
173 if !strings.Contains(result, "list.") {
174 result = strings.Replace(result, `import "list"`, "", 1)
175 }
176
177 return result
178}
179
180// stripHiddenDefBlocks removes hidden definition blocks (#_Foo: { ... }) and
181// collapses references to them back to their underlying public type. Hidden
182// definitions are validation-only wrappers (marked @go(-)) whose structural
183// shape causes cross-context subsumption false positives — the same class of
184// noise as time.Format and list.Contains.
185func stripHiddenDefBlocks(content string) string {
186 hiddenDefs := findHiddenDefs(content)
187
188 var out []string
189 lines := strings.Split(content, "\n")
190 i := 0
191 for i < len(lines) {
192 trimmed := strings.TrimSpace(lines[i])
193
194 if strings.HasPrefix(trimmed, "#_") && strings.Contains(trimmed, ":") {
195 defName := strings.TrimSpace(strings.SplitN(trimmed, ":", 2)[0])
196 if _, ok := hiddenDefs[defName]; ok {
197 for len(out) > 0 && strings.HasPrefix(strings.TrimSpace(out[len(out)-1]), "//") {
198 out = out[:len(out)-1]
199 }
200 depth := 0
201 for i < len(lines) {
202 code := strings.TrimSpace(lines[i])
203 if !strings.HasPrefix(code, "//") {
204 for _, ch := range code {
205 if ch == '{' {
206 depth++
207 } else if ch == '}' {
208 depth--
209 }
210 }
211 }
212 i++
213 if depth <= 0 {
214 break
215 }
216 }
217 continue
218 }
219 }
220
221 out = append(out, lines[i])
222 i++
223 }
224 result := strings.Join(out, "\n")
225
226 for name, base := range hiddenDefs {
227 result = strings.ReplaceAll(result, name, base)
228 }
229 return result
230}
231
232// findHiddenDefs scans for #_Foo: { @go(-) } & #Bar patterns and returns
233// a map from hidden name to underlying public type.
234func findHiddenDefs(content string) map[string]string {
235 defs := make(map[string]string)
236 lines := strings.Split(content, "\n")
237 for i, line := range lines {
238 trimmed := strings.TrimSpace(line)
239 if !strings.HasPrefix(trimmed, "#_") || !strings.Contains(trimmed, ":") {
240 continue
241 }
242 name := strings.TrimSpace(strings.SplitN(trimmed, ":", 2)[0])
243 depth := 0
244 for j := i; j < len(lines); j++ {
245 for _, ch := range lines[j] {
246 if ch == '{' {
247 depth++
248 } else if ch == '}' {
249 depth--
250 }
251 }
252 if idx := strings.Index(lines[j], "& #"); idx >= 0 {
253 rest := lines[j][idx+2:]
254 parts := strings.Fields(rest)
255 if len(parts) > 0 {
256 base := strings.TrimRight(parts[0], " &{")
257 defs[name] = base
258 break
259 }
260 }
261 if depth <= 0 && j > i {
262 break
263 }
264 }
265 }
266 return defs
267}
268
269func collectStableDefs(schemaDir string) ([]string, error) {
270 var stableDefs []string
271
272 entries, err := os.ReadDir(schemaDir)
273 if err != nil {
274 return nil, fmt.Errorf("read schema dir: %w", err)
275 }
276
277 for _, entry := range entries {
278 if !strings.HasSuffix(entry.Name(), ".cue") {
279 continue
280 }
281 data, err := os.ReadFile(filepath.Join(schemaDir, entry.Name()))
282 if err != nil {
283 return nil, fmt.Errorf("read %s: %w", entry.Name(), err)
284 }
285 content := string(data)
286 if !strings.Contains(content, `@status("stable")`) {
287 continue
288 }
289 for _, line := range strings.Split(content, "\n") {
290 line = strings.TrimSpace(line)
291 if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#_") && strings.Contains(line, ":") {
292 def := strings.TrimSpace(strings.SplitN(line, ":", 2)[0])
293 stableDefs = append(stableDefs, def)
294 }
295 }
296 }
297 return stableDefs, nil
298}
299
300func latestVersion(ctx context.Context, client *modregistry.Client, modPath string, includePrerelease bool) (module.Version, error) {
301 versions, err := client.ModuleVersions(ctx, modPath+"@v1")
302 if err != nil {
303 return module.Version{}, fmt.Errorf("listing versions for %s: %w", modPath, err)
304 }
305 for i := len(versions) - 1; i >= 0; i-- {
306 v := versions[i]
307 if includePrerelease || semver.Prerelease(v) == "" {
308 return module.NewVersion(modPath, v)
309 }
310 }
311 if includePrerelease {
312 return module.Version{}, fmt.Errorf("no versions found for %s", modPath)
313 }
314 return module.Version{}, fmt.Errorf("no stable release found for %s (set GEMARA_COMPAT_PRERELEASE=true to include pre-releases)", modPath)
315}
316
317func loadModuleFromRegistry(reg modconfig.Registry, ver module.Version) (cue.Value, error) {
318 instances := load.Instances([]string{ver.String()}, &load.Config{
319 Registry: reg,
320 })
321 if len(instances) == 0 {
322 return cue.Value{}, fmt.Errorf("no CUE instances returned for %v", ver)
323 }
324 if err := instances[0].Err; err != nil {
325 return cue.Value{}, fmt.Errorf("loading module %v: %w", ver, err)
326 }
327 val := schemaCtx.BuildInstance(instances[0])
328 if err := val.Err(); err != nil {
329 return cue.Value{}, fmt.Errorf("building schema for %v: %w", ver, err)
330 }
331 return val, nil
332}