tangled
alpha
login
or
join now
bnewbold.net
/
cobalt
13
fork
atom
go scratch code for atproto
13
fork
atom
overview
issues
pulls
pipelines
refactor linting code in to lexlint pkg
bnewbold.net
3 months ago
557865cc
e8b8a239
+736
-712
5 changed files
expand all
collapse all
unified
split
cmd
glot
breaking.go
lexlint
breaking.go
lint.go
syntax.go
lint.go
+2
-364
cmd/glot/breaking.go
···
4
"context"
5
"encoding/json"
6
"fmt"
7
-
"log/slog"
8
"reflect"
9
-
"sort"
10
11
"github.com/bluesky-social/indigo/atproto/atdata"
12
"github.com/bluesky-social/indigo/atproto/lexicon"
13
"github.com/bluesky-social/indigo/atproto/syntax"
0
14
15
"github.com/urfave/cli/v3"
16
)
···
86
return err
87
}
88
89
-
issues := breakingMaps(nsid, local.Defs, remote.Defs)
90
91
if cmd.Bool("json") {
92
for _, iss := range issues {
···
111
}
112
return nil
113
}
114
-
115
-
func breakingMaps(nsid syntax.NSID, localMap, remoteMap map[string]lexicon.SchemaDef) []LintIssue {
116
-
issues := []LintIssue{}
117
-
118
-
// TODO: maybe only care about the intersection of keys, not union?
119
-
keyMap := map[string]bool{}
120
-
for k := range localMap {
121
-
keyMap[k] = true
122
-
}
123
-
for k := range remoteMap {
124
-
keyMap[k] = true
125
-
}
126
-
keys := []string{}
127
-
for k := range keyMap {
128
-
keys = append(keys, k)
129
-
}
130
-
sort.Strings(keys)
131
-
132
-
for _, k := range keys {
133
-
// NOTE: adding or removing an entire definition or sub-object doesn't break anything
134
-
local, ok := localMap[k]
135
-
if !ok {
136
-
continue
137
-
}
138
-
remote, ok := remoteMap[k]
139
-
if !ok {
140
-
continue
141
-
}
142
-
143
-
nestIssues := breakingDefs(nsid, k, local, remote)
144
-
if len(nestIssues) > 0 {
145
-
issues = append(issues, nestIssues...)
146
-
}
147
-
}
148
-
149
-
return issues
150
-
}
151
-
152
-
func breakingDefs(nsid syntax.NSID, name string, local, remote lexicon.SchemaDef) []LintIssue {
153
-
issues := []LintIssue{}
154
-
155
-
// TODO: in some situations this sort of change might actually be allowed?
156
-
if reflect.TypeOf(local) != reflect.TypeOf(remote) {
157
-
issues = append(issues, LintIssue{
158
-
NSID: nsid,
159
-
LintLevel: "error",
160
-
LintName: "type-change",
161
-
LintDescription: "schema definition type changed",
162
-
Message: fmt.Sprintf("schema type changed (%s): %T != %T", name, local, remote),
163
-
})
164
-
return issues
165
-
}
166
-
167
-
switch l := local.Inner.(type) {
168
-
case lexicon.SchemaRecord:
169
-
slog.Debug("checking record", "name", name, "nsid", nsid)
170
-
r := remote.Inner.(lexicon.SchemaRecord)
171
-
if l.Key != r.Key {
172
-
issues = append(issues, LintIssue{
173
-
NSID: nsid,
174
-
LintLevel: "error",
175
-
LintName: "record-key-type",
176
-
LintDescription: "record key type changed",
177
-
Message: fmt.Sprintf("schema type changed (%s): %s != %s", name, l.Key, r.Key),
178
-
})
179
-
}
180
-
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: l.Record}, lexicon.SchemaDef{Inner: r.Record})...)
181
-
case lexicon.SchemaQuery:
182
-
r := remote.Inner.(lexicon.SchemaQuery)
183
-
// TODO: situation where overall parameters added/removed, and required fields involved
184
-
if l.Parameters != nil && r.Parameters != nil {
185
-
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Parameters}, lexicon.SchemaDef{Inner: *r.Parameters})...)
186
-
}
187
-
// TODO: situation where output requirement changes
188
-
if l.Output != nil && r.Output != nil {
189
-
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Output}, lexicon.SchemaDef{Inner: *r.Output})...)
190
-
}
191
-
// TODO: do Errors matter?
192
-
case lexicon.SchemaProcedure:
193
-
r := remote.Inner.(lexicon.SchemaProcedure)
194
-
// TODO: situation where overall parameters added/removed, and required fields involved
195
-
if l.Parameters != nil && r.Parameters != nil {
196
-
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Parameters}, lexicon.SchemaDef{Inner: *r.Parameters})...)
197
-
}
198
-
// TODO: situation where output requirement changes
199
-
if l.Input != nil && r.Input != nil {
200
-
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Input}, lexicon.SchemaDef{Inner: *r.Input})...)
201
-
}
202
-
// TODO: situation where output requirement changes
203
-
if l.Output != nil && r.Output != nil {
204
-
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Output}, lexicon.SchemaDef{Inner: *r.Output})...)
205
-
}
206
-
// TODO: do Errors matter?
207
-
// TODO: lexicon.SchemaSubscription (and SchemaMessage)
208
-
// TODO: lexicon.SchemaPermissionSet (and SchemaPermission)
209
-
case lexicon.SchemaBody:
210
-
r := remote.Inner.(lexicon.SchemaBody)
211
-
if l.Encoding != r.Encoding {
212
-
issues = append(issues, LintIssue{
213
-
NSID: nsid,
214
-
LintLevel: "error",
215
-
LintName: "body-encoding",
216
-
LintDescription: "API endpoint body content type (encoding) changed",
217
-
Message: fmt.Sprintf("body encoding changed (%s): %s != %s", name, l.Encoding, r.Encoding),
218
-
})
219
-
}
220
-
if l.Schema != nil && r.Schema != nil {
221
-
issues = append(issues, breakingDefs(nsid, name, *l.Schema, *r.Schema)...)
222
-
}
223
-
case lexicon.SchemaBoolean:
224
-
r := remote.Inner.(lexicon.SchemaBoolean)
225
-
// NOTE: default can change safely
226
-
if !eqOptBool(l.Const, r.Const) {
227
-
issues = append(issues, LintIssue{
228
-
NSID: nsid,
229
-
LintLevel: "error",
230
-
LintName: "const-value",
231
-
LintDescription: "schema const value change",
232
-
Message: fmt.Sprintf("const value changed (%s): %v != %v", name, l.Const, r.Const),
233
-
})
234
-
}
235
-
case lexicon.SchemaInteger:
236
-
r := remote.Inner.(lexicon.SchemaInteger)
237
-
// NOTE: default can change safely
238
-
if !eqOptInt(l.Const, r.Const) {
239
-
issues = append(issues, LintIssue{
240
-
NSID: nsid,
241
-
LintLevel: "error",
242
-
LintName: "const-value",
243
-
LintDescription: "schema const value change",
244
-
Message: fmt.Sprintf("const value changed (%s): %v != %v", name, l.Const, r.Const),
245
-
})
246
-
}
247
-
sort.Ints(l.Enum)
248
-
sort.Ints(r.Enum)
249
-
if !reflect.DeepEqual(l.Enum, r.Enum) {
250
-
issues = append(issues, LintIssue{
251
-
NSID: nsid,
252
-
LintLevel: "error",
253
-
LintName: "enum-values",
254
-
LintDescription: "schema enum values change",
255
-
Message: fmt.Sprintf("integer enum value changed (%s)", name),
256
-
})
257
-
}
258
-
if !eqOptInt(l.Minimum, r.Minimum) || !eqOptInt(l.Maximum, r.Maximum) {
259
-
issues = append(issues, LintIssue{
260
-
NSID: nsid,
261
-
LintLevel: "warn",
262
-
LintName: "integer-range",
263
-
LintDescription: "schema min/max values change",
264
-
Message: fmt.Sprintf("integer min/max values changed (%s)", name),
265
-
})
266
-
}
267
-
case lexicon.SchemaString:
268
-
r := remote.Inner.(lexicon.SchemaString)
269
-
// NOTE: default can change safely
270
-
if !eqOptString(l.Const, r.Const) {
271
-
issues = append(issues, LintIssue{
272
-
NSID: nsid,
273
-
LintLevel: "error",
274
-
LintName: "const-value",
275
-
LintDescription: "schema const value change",
276
-
Message: fmt.Sprintf("const value changed (%s)", name),
277
-
})
278
-
}
279
-
sort.Strings(l.Enum)
280
-
sort.Strings(r.Enum)
281
-
if !reflect.DeepEqual(l.Enum, r.Enum) {
282
-
issues = append(issues, LintIssue{
283
-
NSID: nsid,
284
-
LintLevel: "error",
285
-
LintName: "enum-values",
286
-
LintDescription: "schema enum values change",
287
-
Message: fmt.Sprintf("string enum value changed (%s)", name),
288
-
})
289
-
}
290
-
// NOTE: known values can change safely
291
-
if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) || !eqOptInt(l.MinGraphemes, r.MinGraphemes) || !eqOptInt(l.MaxGraphemes, r.MaxGraphemes) {
292
-
issues = append(issues, LintIssue{
293
-
NSID: nsid,
294
-
LintLevel: "warn",
295
-
LintName: "string-length",
296
-
LintDescription: "string min/max length change",
297
-
Message: fmt.Sprintf("string min/max length change (%s)", name),
298
-
})
299
-
}
300
-
if !eqOptString(l.Format, r.Format) {
301
-
issues = append(issues, LintIssue{
302
-
NSID: nsid,
303
-
LintLevel: "error",
304
-
LintName: "string-format",
305
-
LintDescription: "string format change",
306
-
Message: fmt.Sprintf("string format changed (%s)", name),
307
-
})
308
-
}
309
-
case lexicon.SchemaBytes:
310
-
r := remote.Inner.(lexicon.SchemaBytes)
311
-
if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) {
312
-
issues = append(issues, LintIssue{
313
-
NSID: nsid,
314
-
LintLevel: "warn",
315
-
LintName: "bytes-length",
316
-
LintDescription: "bytes min/max length change",
317
-
Message: fmt.Sprintf("bytes min/max length change (%s)", name),
318
-
})
319
-
}
320
-
case lexicon.SchemaCIDLink:
321
-
// pass
322
-
case lexicon.SchemaArray:
323
-
r := remote.Inner.(lexicon.SchemaArray)
324
-
if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) {
325
-
issues = append(issues, LintIssue{
326
-
NSID: nsid,
327
-
LintLevel: "warn",
328
-
LintName: "array-length",
329
-
LintDescription: "array min/max length change",
330
-
Message: fmt.Sprintf("array min/max length change (%s)", name),
331
-
})
332
-
}
333
-
issues = append(issues, breakingDefs(nsid, name, l.Items, r.Items)...)
334
-
case lexicon.SchemaObject:
335
-
r := remote.Inner.(lexicon.SchemaObject)
336
-
sort.Strings(l.Required)
337
-
sort.Strings(r.Required)
338
-
if !reflect.DeepEqual(l.Required, r.Required) {
339
-
issues = append(issues, LintIssue{
340
-
NSID: nsid,
341
-
LintLevel: "error",
342
-
LintName: "object-required",
343
-
LintDescription: "change in which fields are required",
344
-
Message: fmt.Sprintf("required fields change (%s)", name),
345
-
})
346
-
}
347
-
sort.Strings(l.Nullable)
348
-
sort.Strings(r.Nullable)
349
-
if !reflect.DeepEqual(l.Nullable, r.Nullable) {
350
-
issues = append(issues, LintIssue{
351
-
NSID: nsid,
352
-
LintLevel: "error",
353
-
LintName: "object-nullable",
354
-
LintDescription: "change in which fields are nullable",
355
-
Message: fmt.Sprintf("nullable fields change (%s)", name),
356
-
})
357
-
}
358
-
issues = append(issues, breakingMaps(nsid, l.Properties, r.Properties)...)
359
-
case lexicon.SchemaBlob:
360
-
r := remote.Inner.(lexicon.SchemaBlob)
361
-
sort.Strings(l.Accept)
362
-
sort.Strings(r.Accept)
363
-
if !reflect.DeepEqual(l.Accept, r.Accept) {
364
-
// TODO: how strong of a warning should this be?
365
-
issues = append(issues, LintIssue{
366
-
NSID: nsid,
367
-
LintLevel: "warn",
368
-
LintName: "blob-accept",
369
-
LintDescription: "change in blob accept (content-type)",
370
-
Message: fmt.Sprintf("blob accept change (%s)", name),
371
-
})
372
-
}
373
-
if !eqOptInt(l.MaxSize, r.MaxSize) {
374
-
issues = append(issues, LintIssue{
375
-
NSID: nsid,
376
-
LintLevel: "warn",
377
-
LintName: "blob-size",
378
-
LintDescription: "blob maximum size change",
379
-
Message: fmt.Sprintf("blob max size change (%s)", name),
380
-
})
381
-
}
382
-
case lexicon.SchemaParams:
383
-
r := remote.Inner.(lexicon.SchemaParams)
384
-
sort.Strings(l.Required)
385
-
sort.Strings(r.Required)
386
-
if !reflect.DeepEqual(l.Required, r.Required) {
387
-
issues = append(issues, LintIssue{
388
-
NSID: nsid,
389
-
LintLevel: "error",
390
-
LintName: "params-required",
391
-
LintDescription: "change in which fields are required",
392
-
Message: fmt.Sprintf("required fields change (%s)", name),
393
-
})
394
-
}
395
-
issues = append(issues, breakingMaps(nsid, l.Properties, r.Properties)...)
396
-
case lexicon.SchemaToken:
397
-
// pass
398
-
case lexicon.SchemaRef:
399
-
r := remote.Inner.(lexicon.SchemaRef)
400
-
if l.Ref != r.Ref {
401
-
// NOTE: if the underlying schemas are the same this could be ok in some situations
402
-
issues = append(issues, LintIssue{
403
-
NSID: nsid,
404
-
LintLevel: "warn",
405
-
LintName: "ref-change",
406
-
LintDescription: "change in referenced lexicon",
407
-
Message: fmt.Sprintf("ref change (%s): %s != %s", name, l.Ref, r.Ref),
408
-
})
409
-
}
410
-
case lexicon.SchemaUnion:
411
-
r := remote.Inner.(lexicon.SchemaUnion)
412
-
if !eqOptBool(l.Closed, r.Closed) {
413
-
// TODO: going from default to explicit should be ok...
414
-
issues = append(issues, LintIssue{
415
-
NSID: nsid,
416
-
LintLevel: "error",
417
-
LintName: "union-open-closed",
418
-
LintDescription: "can't change union between open and closed",
419
-
Message: fmt.Sprintf("union open/closed type changed (%s)", name),
420
-
})
421
-
}
422
-
// TODO: closed union and refs change
423
-
if l.Closed != nil && *l.Closed {
424
-
sort.Strings(l.Refs)
425
-
sort.Strings(r.Refs)
426
-
if !reflect.DeepEqual(l.Refs, r.Refs) {
427
-
issues = append(issues, LintIssue{
428
-
NSID: nsid,
429
-
LintLevel: "error",
430
-
LintName: "union-closed-refs",
431
-
LintDescription: "closed unions can not have types (refs) change",
432
-
Message: fmt.Sprintf("closed union types (refs) changed (%s)", name),
433
-
})
434
-
}
435
-
}
436
-
case lexicon.SchemaUnknown:
437
-
// pass
438
-
default:
439
-
slog.Warn("unhandled schema def type in breaking check", "type", reflect.TypeOf(local.Inner))
440
-
}
441
-
442
-
return issues
443
-
}
444
-
445
-
// helper to check if two optional (pointer) integers are equal/consistent
446
-
func eqOptInt(a, b *int) bool {
447
-
if a == nil {
448
-
return b == nil
449
-
}
450
-
if b == nil {
451
-
return false
452
-
}
453
-
return *a == *b
454
-
}
455
-
456
-
func eqOptBool(a, b *bool) bool {
457
-
if a == nil {
458
-
return b == nil
459
-
}
460
-
if b == nil {
461
-
return false
462
-
}
463
-
return *a == *b
464
-
}
465
-
466
-
func eqOptString(a, b *string) bool {
467
-
if a == nil {
468
-
return b == nil
469
-
}
470
-
if b == nil {
471
-
return false
472
-
}
473
-
return *a == *b
474
-
}
···
4
"context"
5
"encoding/json"
6
"fmt"
0
7
"reflect"
0
8
9
"github.com/bluesky-social/indigo/atproto/atdata"
10
"github.com/bluesky-social/indigo/atproto/lexicon"
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"tangled.org/bnewbold.net/cobalt/cmd/glot/lexlint"
13
14
"github.com/urfave/cli/v3"
15
)
···
85
return err
86
}
87
88
+
issues := lexlint.BreakingChanges(&remote, &local)
89
90
if cmd.Bool("json") {
91
for _, iss := range issues {
···
110
}
111
return nil
112
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+376
cmd/glot/lexlint/breaking.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package lexlint
2
+
3
+
import (
4
+
"fmt"
5
+
"log/slog"
6
+
"reflect"
7
+
"sort"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/lexicon"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
)
12
+
13
+
func BreakingChanges(before, after *lexicon.SchemaFile) []LintIssue {
14
+
return breakingMaps(syntax.NSID(before.ID), before.Defs, after.Defs)
15
+
}
16
+
17
+
func breakingMaps(nsid syntax.NSID, localMap, remoteMap map[string]lexicon.SchemaDef) []LintIssue {
18
+
issues := []LintIssue{}
19
+
20
+
// TODO: maybe only care about the intersection of keys, not union?
21
+
keyMap := map[string]bool{}
22
+
for k := range localMap {
23
+
keyMap[k] = true
24
+
}
25
+
for k := range remoteMap {
26
+
keyMap[k] = true
27
+
}
28
+
keys := []string{}
29
+
for k := range keyMap {
30
+
keys = append(keys, k)
31
+
}
32
+
sort.Strings(keys)
33
+
34
+
for _, k := range keys {
35
+
// NOTE: adding or removing an entire definition or sub-object doesn't break anything
36
+
local, ok := localMap[k]
37
+
if !ok {
38
+
continue
39
+
}
40
+
remote, ok := remoteMap[k]
41
+
if !ok {
42
+
continue
43
+
}
44
+
45
+
nestIssues := breakingDefs(nsid, k, local, remote)
46
+
if len(nestIssues) > 0 {
47
+
issues = append(issues, nestIssues...)
48
+
}
49
+
}
50
+
51
+
return issues
52
+
}
53
+
54
+
func breakingDefs(nsid syntax.NSID, name string, local, remote lexicon.SchemaDef) []LintIssue {
55
+
issues := []LintIssue{}
56
+
57
+
// TODO: in some situations this sort of change might actually be allowed?
58
+
if reflect.TypeOf(local) != reflect.TypeOf(remote) {
59
+
issues = append(issues, LintIssue{
60
+
NSID: nsid,
61
+
LintLevel: "error",
62
+
LintName: "type-change",
63
+
LintDescription: "schema definition type changed",
64
+
Message: fmt.Sprintf("schema type changed (%s): %T != %T", name, local, remote),
65
+
})
66
+
return issues
67
+
}
68
+
69
+
switch l := local.Inner.(type) {
70
+
case lexicon.SchemaRecord:
71
+
slog.Debug("checking record", "name", name, "nsid", nsid)
72
+
r := remote.Inner.(lexicon.SchemaRecord)
73
+
if l.Key != r.Key {
74
+
issues = append(issues, LintIssue{
75
+
NSID: nsid,
76
+
LintLevel: "error",
77
+
LintName: "record-key-type",
78
+
LintDescription: "record key type changed",
79
+
Message: fmt.Sprintf("schema type changed (%s): %s != %s", name, l.Key, r.Key),
80
+
})
81
+
}
82
+
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: l.Record}, lexicon.SchemaDef{Inner: r.Record})...)
83
+
case lexicon.SchemaQuery:
84
+
r := remote.Inner.(lexicon.SchemaQuery)
85
+
// TODO: situation where overall parameters added/removed, and required fields involved
86
+
if l.Parameters != nil && r.Parameters != nil {
87
+
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Parameters}, lexicon.SchemaDef{Inner: *r.Parameters})...)
88
+
}
89
+
// TODO: situation where output requirement changes
90
+
if l.Output != nil && r.Output != nil {
91
+
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Output}, lexicon.SchemaDef{Inner: *r.Output})...)
92
+
}
93
+
// TODO: do Errors matter?
94
+
case lexicon.SchemaProcedure:
95
+
r := remote.Inner.(lexicon.SchemaProcedure)
96
+
// TODO: situation where overall parameters added/removed, and required fields involved
97
+
if l.Parameters != nil && r.Parameters != nil {
98
+
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Parameters}, lexicon.SchemaDef{Inner: *r.Parameters})...)
99
+
}
100
+
// TODO: situation where output requirement changes
101
+
if l.Input != nil && r.Input != nil {
102
+
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Input}, lexicon.SchemaDef{Inner: *r.Input})...)
103
+
}
104
+
// TODO: situation where output requirement changes
105
+
if l.Output != nil && r.Output != nil {
106
+
issues = append(issues, breakingDefs(nsid, name, lexicon.SchemaDef{Inner: *l.Output}, lexicon.SchemaDef{Inner: *r.Output})...)
107
+
}
108
+
// TODO: do Errors matter?
109
+
// TODO: lexicon.SchemaSubscription (and SchemaMessage)
110
+
// TODO: lexicon.SchemaPermissionSet (and SchemaPermission)
111
+
case lexicon.SchemaBody:
112
+
r := remote.Inner.(lexicon.SchemaBody)
113
+
if l.Encoding != r.Encoding {
114
+
issues = append(issues, LintIssue{
115
+
NSID: nsid,
116
+
LintLevel: "error",
117
+
LintName: "body-encoding",
118
+
LintDescription: "API endpoint body content type (encoding) changed",
119
+
Message: fmt.Sprintf("body encoding changed (%s): %s != %s", name, l.Encoding, r.Encoding),
120
+
})
121
+
}
122
+
if l.Schema != nil && r.Schema != nil {
123
+
issues = append(issues, breakingDefs(nsid, name, *l.Schema, *r.Schema)...)
124
+
}
125
+
case lexicon.SchemaBoolean:
126
+
r := remote.Inner.(lexicon.SchemaBoolean)
127
+
// NOTE: default can change safely
128
+
if !eqOptBool(l.Const, r.Const) {
129
+
issues = append(issues, LintIssue{
130
+
NSID: nsid,
131
+
LintLevel: "error",
132
+
LintName: "const-value",
133
+
LintDescription: "schema const value change",
134
+
Message: fmt.Sprintf("const value changed (%s): %v != %v", name, l.Const, r.Const),
135
+
})
136
+
}
137
+
case lexicon.SchemaInteger:
138
+
r := remote.Inner.(lexicon.SchemaInteger)
139
+
// NOTE: default can change safely
140
+
if !eqOptInt(l.Const, r.Const) {
141
+
issues = append(issues, LintIssue{
142
+
NSID: nsid,
143
+
LintLevel: "error",
144
+
LintName: "const-value",
145
+
LintDescription: "schema const value change",
146
+
Message: fmt.Sprintf("const value changed (%s): %v != %v", name, l.Const, r.Const),
147
+
})
148
+
}
149
+
sort.Ints(l.Enum)
150
+
sort.Ints(r.Enum)
151
+
if !reflect.DeepEqual(l.Enum, r.Enum) {
152
+
issues = append(issues, LintIssue{
153
+
NSID: nsid,
154
+
LintLevel: "error",
155
+
LintName: "enum-values",
156
+
LintDescription: "schema enum values change",
157
+
Message: fmt.Sprintf("integer enum value changed (%s)", name),
158
+
})
159
+
}
160
+
if !eqOptInt(l.Minimum, r.Minimum) || !eqOptInt(l.Maximum, r.Maximum) {
161
+
issues = append(issues, LintIssue{
162
+
NSID: nsid,
163
+
LintLevel: "warn",
164
+
LintName: "integer-range",
165
+
LintDescription: "schema min/max values change",
166
+
Message: fmt.Sprintf("integer min/max values changed (%s)", name),
167
+
})
168
+
}
169
+
case lexicon.SchemaString:
170
+
r := remote.Inner.(lexicon.SchemaString)
171
+
// NOTE: default can change safely
172
+
if !eqOptString(l.Const, r.Const) {
173
+
issues = append(issues, LintIssue{
174
+
NSID: nsid,
175
+
LintLevel: "error",
176
+
LintName: "const-value",
177
+
LintDescription: "schema const value change",
178
+
Message: fmt.Sprintf("const value changed (%s)", name),
179
+
})
180
+
}
181
+
sort.Strings(l.Enum)
182
+
sort.Strings(r.Enum)
183
+
if !reflect.DeepEqual(l.Enum, r.Enum) {
184
+
issues = append(issues, LintIssue{
185
+
NSID: nsid,
186
+
LintLevel: "error",
187
+
LintName: "enum-values",
188
+
LintDescription: "schema enum values change",
189
+
Message: fmt.Sprintf("string enum value changed (%s)", name),
190
+
})
191
+
}
192
+
// NOTE: known values can change safely
193
+
if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) || !eqOptInt(l.MinGraphemes, r.MinGraphemes) || !eqOptInt(l.MaxGraphemes, r.MaxGraphemes) {
194
+
issues = append(issues, LintIssue{
195
+
NSID: nsid,
196
+
LintLevel: "warn",
197
+
LintName: "string-length",
198
+
LintDescription: "string min/max length change",
199
+
Message: fmt.Sprintf("string min/max length change (%s)", name),
200
+
})
201
+
}
202
+
if !eqOptString(l.Format, r.Format) {
203
+
issues = append(issues, LintIssue{
204
+
NSID: nsid,
205
+
LintLevel: "error",
206
+
LintName: "string-format",
207
+
LintDescription: "string format change",
208
+
Message: fmt.Sprintf("string format changed (%s)", name),
209
+
})
210
+
}
211
+
case lexicon.SchemaBytes:
212
+
r := remote.Inner.(lexicon.SchemaBytes)
213
+
if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) {
214
+
issues = append(issues, LintIssue{
215
+
NSID: nsid,
216
+
LintLevel: "warn",
217
+
LintName: "bytes-length",
218
+
LintDescription: "bytes min/max length change",
219
+
Message: fmt.Sprintf("bytes min/max length change (%s)", name),
220
+
})
221
+
}
222
+
case lexicon.SchemaCIDLink:
223
+
// pass
224
+
case lexicon.SchemaArray:
225
+
r := remote.Inner.(lexicon.SchemaArray)
226
+
if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) {
227
+
issues = append(issues, LintIssue{
228
+
NSID: nsid,
229
+
LintLevel: "warn",
230
+
LintName: "array-length",
231
+
LintDescription: "array min/max length change",
232
+
Message: fmt.Sprintf("array min/max length change (%s)", name),
233
+
})
234
+
}
235
+
issues = append(issues, breakingDefs(nsid, name, l.Items, r.Items)...)
236
+
case lexicon.SchemaObject:
237
+
r := remote.Inner.(lexicon.SchemaObject)
238
+
sort.Strings(l.Required)
239
+
sort.Strings(r.Required)
240
+
if !reflect.DeepEqual(l.Required, r.Required) {
241
+
issues = append(issues, LintIssue{
242
+
NSID: nsid,
243
+
LintLevel: "error",
244
+
LintName: "object-required",
245
+
LintDescription: "change in which fields are required",
246
+
Message: fmt.Sprintf("required fields change (%s)", name),
247
+
})
248
+
}
249
+
sort.Strings(l.Nullable)
250
+
sort.Strings(r.Nullable)
251
+
if !reflect.DeepEqual(l.Nullable, r.Nullable) {
252
+
issues = append(issues, LintIssue{
253
+
NSID: nsid,
254
+
LintLevel: "error",
255
+
LintName: "object-nullable",
256
+
LintDescription: "change in which fields are nullable",
257
+
Message: fmt.Sprintf("nullable fields change (%s)", name),
258
+
})
259
+
}
260
+
issues = append(issues, breakingMaps(nsid, l.Properties, r.Properties)...)
261
+
case lexicon.SchemaBlob:
262
+
r := remote.Inner.(lexicon.SchemaBlob)
263
+
sort.Strings(l.Accept)
264
+
sort.Strings(r.Accept)
265
+
if !reflect.DeepEqual(l.Accept, r.Accept) {
266
+
// TODO: how strong of a warning should this be?
267
+
issues = append(issues, LintIssue{
268
+
NSID: nsid,
269
+
LintLevel: "warn",
270
+
LintName: "blob-accept",
271
+
LintDescription: "change in blob accept (content-type)",
272
+
Message: fmt.Sprintf("blob accept change (%s)", name),
273
+
})
274
+
}
275
+
if !eqOptInt(l.MaxSize, r.MaxSize) {
276
+
issues = append(issues, LintIssue{
277
+
NSID: nsid,
278
+
LintLevel: "warn",
279
+
LintName: "blob-size",
280
+
LintDescription: "blob maximum size change",
281
+
Message: fmt.Sprintf("blob max size change (%s)", name),
282
+
})
283
+
}
284
+
case lexicon.SchemaParams:
285
+
r := remote.Inner.(lexicon.SchemaParams)
286
+
sort.Strings(l.Required)
287
+
sort.Strings(r.Required)
288
+
if !reflect.DeepEqual(l.Required, r.Required) {
289
+
issues = append(issues, LintIssue{
290
+
NSID: nsid,
291
+
LintLevel: "error",
292
+
LintName: "params-required",
293
+
LintDescription: "change in which fields are required",
294
+
Message: fmt.Sprintf("required fields change (%s)", name),
295
+
})
296
+
}
297
+
issues = append(issues, breakingMaps(nsid, l.Properties, r.Properties)...)
298
+
case lexicon.SchemaToken:
299
+
// pass
300
+
case lexicon.SchemaRef:
301
+
r := remote.Inner.(lexicon.SchemaRef)
302
+
if l.Ref != r.Ref {
303
+
// NOTE: if the underlying schemas are the same this could be ok in some situations
304
+
issues = append(issues, LintIssue{
305
+
NSID: nsid,
306
+
LintLevel: "warn",
307
+
LintName: "ref-change",
308
+
LintDescription: "change in referenced lexicon",
309
+
Message: fmt.Sprintf("ref change (%s): %s != %s", name, l.Ref, r.Ref),
310
+
})
311
+
}
312
+
case lexicon.SchemaUnion:
313
+
r := remote.Inner.(lexicon.SchemaUnion)
314
+
if !eqOptBool(l.Closed, r.Closed) {
315
+
// TODO: going from default to explicit should be ok...
316
+
issues = append(issues, LintIssue{
317
+
NSID: nsid,
318
+
LintLevel: "error",
319
+
LintName: "union-open-closed",
320
+
LintDescription: "can't change union between open and closed",
321
+
Message: fmt.Sprintf("union open/closed type changed (%s)", name),
322
+
})
323
+
}
324
+
// TODO: closed union and refs change
325
+
if l.Closed != nil && *l.Closed {
326
+
sort.Strings(l.Refs)
327
+
sort.Strings(r.Refs)
328
+
if !reflect.DeepEqual(l.Refs, r.Refs) {
329
+
issues = append(issues, LintIssue{
330
+
NSID: nsid,
331
+
LintLevel: "error",
332
+
LintName: "union-closed-refs",
333
+
LintDescription: "closed unions can not have types (refs) change",
334
+
Message: fmt.Sprintf("closed union types (refs) changed (%s)", name),
335
+
})
336
+
}
337
+
}
338
+
case lexicon.SchemaUnknown:
339
+
// pass
340
+
default:
341
+
slog.Warn("unhandled schema def type in breaking check", "type", reflect.TypeOf(local.Inner))
342
+
}
343
+
344
+
return issues
345
+
}
346
+
347
+
// helper to check if two optional (pointer) integers are equal/consistent
348
+
func eqOptInt(a, b *int) bool {
349
+
if a == nil {
350
+
return b == nil
351
+
}
352
+
if b == nil {
353
+
return false
354
+
}
355
+
return *a == *b
356
+
}
357
+
358
+
func eqOptBool(a, b *bool) bool {
359
+
if a == nil {
360
+
return b == nil
361
+
}
362
+
if b == nil {
363
+
return false
364
+
}
365
+
return *a == *b
366
+
}
367
+
368
+
func eqOptString(a, b *string) bool {
369
+
if a == nil {
370
+
return b == nil
371
+
}
372
+
if b == nil {
373
+
return false
374
+
}
375
+
return *a == *b
376
+
}
+328
cmd/glot/lexlint/lint.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package lexlint
2
+
3
+
import (
4
+
"errors"
5
+
"fmt"
6
+
"log/slog"
7
+
"slices"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/lexicon"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
)
12
+
13
+
var (
14
+
// internal error used to set non-zero return code (but not print separately)
15
+
ErrLintFailures = errors.New("linting issues detected")
16
+
)
17
+
18
+
type LintIssue struct {
19
+
FilePath string `json:"file-path,omitempty"`
20
+
NSID syntax.NSID `json:"nsid,omitempty"`
21
+
LintLevel string `json:"lint-level,omitempty"`
22
+
LintName string `json:"lint-name,omitempty"`
23
+
LintDescription string `json:"lint-description,omitempty"`
24
+
Message string `json:"message,omitempty"`
25
+
}
26
+
27
+
func LintSchemaFile(sf *lexicon.SchemaFile) []LintIssue {
28
+
issues := []LintIssue{}
29
+
30
+
nsid, err := syntax.ParseNSID(sf.ID)
31
+
if err != nil {
32
+
issues = append(issues, LintIssue{
33
+
NSID: syntax.NSID(sf.ID),
34
+
LintLevel: "error",
35
+
LintName: "invalid-nsid",
36
+
LintDescription: "schema file declares NSID with invalid syntax",
37
+
Message: fmt.Sprintf("NSID string: %s", sf.ID),
38
+
})
39
+
}
40
+
if nsid == "" {
41
+
nsid = syntax.NSID(sf.ID)
42
+
}
43
+
if sf.Lexicon != 1 {
44
+
issues = append(issues, LintIssue{
45
+
NSID: nsid,
46
+
LintLevel: "error",
47
+
LintName: "lexicon-version",
48
+
LintDescription: "unsupported Lexicon language version",
49
+
Message: fmt.Sprintf("found version: %d", sf.Lexicon),
50
+
})
51
+
return issues
52
+
}
53
+
54
+
for defname, def := range sf.Defs {
55
+
defiss := lintSchemaDef(nsid, defname, def)
56
+
if len(defiss) > 0 {
57
+
issues = append(issues, defiss...)
58
+
}
59
+
}
60
+
61
+
return issues
62
+
}
63
+
64
+
func lintSchemaDef(nsid syntax.NSID, defname string, def lexicon.SchemaDef) []LintIssue {
65
+
issues := []LintIssue{}
66
+
67
+
// missing description issue, in case it is needed
68
+
missingDesc := func() LintIssue {
69
+
return LintIssue{
70
+
NSID: nsid,
71
+
LintLevel: "warn",
72
+
LintName: "missing-primary-description",
73
+
LintDescription: "primary types (record, query, procedure, subscription, permission-set) should include a description",
74
+
Message: "primary type missing a description",
75
+
}
76
+
}
77
+
78
+
if err := def.CheckSchema(); err != nil {
79
+
issues = append(issues, LintIssue{
80
+
NSID: nsid,
81
+
LintLevel: "error",
82
+
LintName: "lexicon-schema",
83
+
LintDescription: "basic structure schema checks (additional errors may be collapsed)",
84
+
Message: err.Error(),
85
+
})
86
+
}
87
+
88
+
if err := CheckSchemaName(defname); err != nil {
89
+
issues = append(issues, LintIssue{
90
+
NSID: nsid,
91
+
LintLevel: "warn",
92
+
LintName: "def-name-syntax",
93
+
LintDescription: "definition name does not follow syntax guidance",
94
+
Message: fmt.Sprintf("%s: %s", err.Error(), defname),
95
+
})
96
+
}
97
+
98
+
if nsid.Name() == "defs" && defname == "main" {
99
+
issues = append(issues, LintIssue{
100
+
NSID: nsid,
101
+
LintLevel: "warn",
102
+
LintName: "defs-main-definition",
103
+
LintDescription: "defs schemas should not have a 'main'",
104
+
Message: "defs schemas should not have a 'main'",
105
+
})
106
+
}
107
+
108
+
switch def.Inner.(type) {
109
+
case lexicon.SchemaRecord, lexicon.SchemaQuery, lexicon.SchemaProcedure, lexicon.SchemaSubscription, lexicon.SchemaPermissionSet:
110
+
if defname != "main" {
111
+
issues = append(issues, LintIssue{
112
+
NSID: nsid,
113
+
LintLevel: "error",
114
+
LintName: "non-main-primary",
115
+
LintDescription: "primary types (record, query, procedure, subscription, permission-set) must be 'main' definition",
116
+
Message: fmt.Sprintf("primary definition types must be 'main': %s", defname),
117
+
})
118
+
}
119
+
}
120
+
121
+
switch v := def.Inner.(type) {
122
+
case lexicon.SchemaRecord:
123
+
if v.Description == nil || *v.Description == "" {
124
+
issues = append(issues, missingDesc())
125
+
}
126
+
reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: v.Record})
127
+
if len(reciss) > 0 {
128
+
issues = append(issues, reciss...)
129
+
}
130
+
case lexicon.SchemaQuery:
131
+
if v.Description == nil || *v.Description == "" {
132
+
issues = append(issues, missingDesc())
133
+
}
134
+
if v.Parameters != nil {
135
+
reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Parameters})
136
+
if len(reciss) > 0 {
137
+
issues = append(issues, reciss...)
138
+
}
139
+
}
140
+
if v.Output == nil {
141
+
issues = append(issues, LintIssue{
142
+
NSID: nsid,
143
+
LintLevel: "warn",
144
+
LintName: "endpoint-output-undefined",
145
+
LintDescription: "API endpoints should define an output (even if empty)",
146
+
Message: "missing output definition",
147
+
})
148
+
} else {
149
+
reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Output})
150
+
if len(reciss) > 0 {
151
+
issues = append(issues, reciss...)
152
+
}
153
+
}
154
+
// TODO: error names
155
+
case lexicon.SchemaProcedure:
156
+
if v.Description == nil || *v.Description == "" {
157
+
issues = append(issues, missingDesc())
158
+
}
159
+
if v.Parameters != nil {
160
+
reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Parameters})
161
+
if len(reciss) > 0 {
162
+
issues = append(issues, reciss...)
163
+
}
164
+
}
165
+
if v.Input != nil {
166
+
reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Input})
167
+
if len(reciss) > 0 {
168
+
issues = append(issues, reciss...)
169
+
}
170
+
}
171
+
if v.Output == nil {
172
+
issues = append(issues, LintIssue{
173
+
NSID: nsid,
174
+
LintLevel: "warn",
175
+
LintName: "endpoint-output-undefined",
176
+
LintDescription: "API endpoints should define an output (even if empty)",
177
+
Message: "missing output definition",
178
+
})
179
+
} else {
180
+
reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Output})
181
+
if len(reciss) > 0 {
182
+
issues = append(issues, reciss...)
183
+
}
184
+
}
185
+
// TODO: error names
186
+
case lexicon.SchemaSubscription:
187
+
if v.Description == nil || *v.Description == "" {
188
+
issues = append(issues, missingDesc())
189
+
}
190
+
if v.Parameters != nil {
191
+
reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: *v.Parameters})
192
+
if len(reciss) > 0 {
193
+
issues = append(issues, reciss...)
194
+
}
195
+
}
196
+
if v.Message != nil {
197
+
// TODO: v.Message.Schema must only have local references (same file), and should have at least one defined
198
+
reciss := lintSchemaRecursive(nsid, lexicon.SchemaDef{Inner: v.Message.Schema})
199
+
if len(reciss) > 0 {
200
+
issues = append(issues, reciss...)
201
+
}
202
+
} else {
203
+
issues = append(issues, LintIssue{
204
+
NSID: nsid,
205
+
LintLevel: "warn",
206
+
LintName: "subscription-no-messages",
207
+
LintDescription: "no subscription message types defined",
208
+
Message: "no subscription message types defined",
209
+
})
210
+
}
211
+
// TODO: at least one message type
212
+
case lexicon.SchemaPermissionSet:
213
+
if v.Title == nil || *v.Title == "" {
214
+
issues = append(issues, LintIssue{
215
+
NSID: nsid,
216
+
LintLevel: "warn",
217
+
LintName: "permissionset-no-title",
218
+
LintDescription: "permission sets should include a title",
219
+
Message: "missing title",
220
+
})
221
+
}
222
+
// TODO: missing detail
223
+
// TODO: translated descriptions?
224
+
if len(v.Permissions) == 0 {
225
+
issues = append(issues, LintIssue{
226
+
NSID: nsid,
227
+
LintLevel: "warn",
228
+
LintName: "permissionset-no-permissions",
229
+
LintDescription: "permission sets should define at least one permission",
230
+
Message: "empty permission set",
231
+
})
232
+
}
233
+
for _, perm := range v.Permissions {
234
+
// TODO: any lints on permissions?
235
+
_ = perm
236
+
}
237
+
case lexicon.SchemaPermission, lexicon.SchemaBoolean, lexicon.SchemaInteger, lexicon.SchemaString, lexicon.SchemaBytes, lexicon.SchemaCIDLink, lexicon.SchemaArray, lexicon.SchemaObject, lexicon.SchemaBlob, lexicon.SchemaToken, lexicon.SchemaRef, lexicon.SchemaUnion, lexicon.SchemaUnknown:
238
+
reciss := lintSchemaRecursive(nsid, def)
239
+
if len(reciss) > 0 {
240
+
issues = append(issues, reciss...)
241
+
}
242
+
default:
243
+
slog.Info("no lint rules for top-level schema definition type", "type", fmt.Sprintf("%T", def.Inner))
244
+
}
245
+
return issues
246
+
}
247
+
248
+
func lintSchemaRecursive(nsid syntax.NSID, def lexicon.SchemaDef) []LintIssue {
249
+
issues := []LintIssue{}
250
+
251
+
switch v := def.Inner.(type) {
252
+
case lexicon.SchemaPermission:
253
+
// TODO: anything?
254
+
case lexicon.SchemaBoolean:
255
+
// TODO: default true
256
+
// TODO: both default and const
257
+
case lexicon.SchemaInteger:
258
+
// TODO: both default and const
259
+
case lexicon.SchemaString:
260
+
// TODO: no format and no max length
261
+
// TODO: format and length limits
262
+
// TODO: grapheme limit set, and maxlen either too low or not set
263
+
// TODO: very large max size
264
+
// TODO: format=handle strings within an record type
265
+
case lexicon.SchemaBytes:
266
+
// TODO: very large max size
267
+
case lexicon.SchemaCIDLink:
268
+
// pass
269
+
case lexicon.SchemaArray:
270
+
reciss := lintSchemaRecursive(nsid, v.Items)
271
+
if len(reciss) > 0 {
272
+
issues = append(issues, reciss...)
273
+
}
274
+
case lexicon.SchemaObject:
275
+
// NOTE: CheckSchema already verifies that nullable and required are valid against property keys
276
+
for _, propdef := range v.Properties {
277
+
reciss := lintSchemaRecursive(nsid, propdef)
278
+
if len(reciss) > 0 {
279
+
issues = append(issues, reciss...)
280
+
}
281
+
// TODO: property name syntax
282
+
}
283
+
for _, k := range v.Nullable {
284
+
if !slices.Contains(v.Required, k) {
285
+
issues = append(issues, LintIssue{
286
+
NSID: nsid,
287
+
LintLevel: "warn",
288
+
LintName: "nullable-and-optional",
289
+
LintDescription: "object properties should not be both optional and nullable",
290
+
Message: fmt.Sprintf("field is both nullable and optional: %s", k),
291
+
})
292
+
}
293
+
}
294
+
case lexicon.SchemaBlob:
295
+
// pass
296
+
case lexicon.SchemaParams:
297
+
// NOTE: CheckSchema already verifies that required are valid against property keys
298
+
for _, propdef := range v.Properties {
299
+
reciss := lintSchemaRecursive(nsid, propdef)
300
+
if len(reciss) > 0 {
301
+
issues = append(issues, reciss...)
302
+
}
303
+
// TODO: property name syntax
304
+
}
305
+
case lexicon.SchemaToken:
306
+
// pass
307
+
case lexicon.SchemaRef:
308
+
// TODO: resolve? locally vs globally?
309
+
case lexicon.SchemaUnion:
310
+
// TODO: open vs closed?
311
+
// TODO: check that refs actually resolve?
312
+
case lexicon.SchemaUnknown:
313
+
// pass
314
+
case lexicon.SchemaBody:
315
+
if v.Schema != nil {
316
+
// NOTE: CheckSchema already verified that v.Schema is an object, ref, or union
317
+
reciss := lintSchemaRecursive(nsid, *v.Schema)
318
+
if len(reciss) > 0 {
319
+
issues = append(issues, reciss...)
320
+
}
321
+
}
322
+
// TODO: empty/invalid Encoding (mimetype)
323
+
default:
324
+
slog.Info("no lint rules for recursive schema type", "type", fmt.Sprintf("%T", def.Inner), "nsid", nsid)
325
+
}
326
+
327
+
return issues
328
+
}
+21
cmd/glot/lexlint/syntax.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package lexlint
2
+
3
+
import (
4
+
"errors"
5
+
"regexp"
6
+
)
7
+
8
+
var schemaNameRegex = regexp.MustCompile(`^[a-zA-Z]([a-zA-Z0-9]{0,255})?$`)
9
+
10
+
func CheckSchemaName(raw string) error {
11
+
if raw == "" {
12
+
return errors.New("empty name")
13
+
}
14
+
if len(raw) > 255 {
15
+
return errors.New("name is too long (255 chars max)")
16
+
}
17
+
if !schemaNameRegex.MatchString(raw) {
18
+
return errors.New("name doesn't match recommended syntax/characters")
19
+
}
20
+
return nil
21
+
}
+9
-348
cmd/glot/lint.go
···
4
"bytes"
5
"context"
6
"encoding/json"
7
-
"errors"
8
"fmt"
9
"log/slog"
10
"os"
11
-
"regexp"
12
-
"slices"
13
14
"github.com/bluesky-social/indigo/atproto/lexicon"
15
"github.com/bluesky-social/indigo/atproto/syntax"
0
16
17
"github.com/urfave/cli/v3"
18
)
19
20
var (
21
-
// internal error used to set non-zero return code (but not print separately)
22
-
ErrLintFailures = errors.New("linting issues detected")
23
)
24
25
var cmdLint = &cli.Command{
···
86
err = sf.FinishParse()
87
}
88
if err != nil {
89
-
iss := LintIssue{
90
FilePath: p,
91
//NSID
92
LintLevel: "error",
···
107
return ErrLintFailures
108
}
109
110
-
issues := lintSchemaFile(p, sf)
0
0
0
0
111
112
// check for unknown fields (more strict, as a lint/warning)
113
var unknownSF lexicon.SchemaFile
114
dec := json.NewDecoder(bytes.NewReader(b))
115
dec.DisallowUnknownFields()
116
if err := dec.Decode(&unknownSF); err != nil {
117
-
issues = append(issues, LintIssue{
118
FilePath: p,
119
NSID: syntax.NSID(sf.ID),
120
LintLevel: "warn",
···
147
}
148
return nil
149
}
150
-
151
-
type LintIssue struct {
152
-
FilePath string `json:"file-path,omitempty"`
153
-
NSID syntax.NSID `json:"nsid,omitempty"`
154
-
LintLevel string `json:"lint-level,omitempty"`
155
-
LintName string `json:"lint-name,omitempty"`
156
-
LintDescription string `json:"lint-description,omitempty"`
157
-
Message string `json:"message,omitempty"`
158
-
}
159
-
160
-
var schemaNameRegex = regexp.MustCompile(`^[a-zA-Z]([a-zA-Z0-9]{0,255})?$`)
161
-
162
-
func checkSchemaName(raw string) error {
163
-
if raw == "" {
164
-
return errors.New("empty name")
165
-
}
166
-
if len(raw) > 255 {
167
-
return errors.New("name is too long (255 chars max)")
168
-
}
169
-
if !schemaNameRegex.MatchString(raw) {
170
-
return errors.New("name doesn't match recommended syntax/characters")
171
-
}
172
-
return nil
173
-
}
174
-
175
-
func lintSchemaFile(p string, sf lexicon.SchemaFile) []LintIssue {
176
-
issues := []LintIssue{}
177
-
178
-
nsid, err := syntax.ParseNSID(sf.ID)
179
-
if err != nil {
180
-
issues = append(issues, LintIssue{
181
-
FilePath: p,
182
-
NSID: syntax.NSID(sf.ID),
183
-
LintLevel: "error",
184
-
LintName: "invalid-nsid",
185
-
LintDescription: "schema file declares NSID with invalid syntax",
186
-
Message: fmt.Sprintf("NSID string: %s", sf.ID),
187
-
})
188
-
}
189
-
if nsid == "" {
190
-
nsid = syntax.NSID(sf.ID)
191
-
}
192
-
if sf.Lexicon != 1 {
193
-
issues = append(issues, LintIssue{
194
-
FilePath: p,
195
-
NSID: nsid,
196
-
LintLevel: "error",
197
-
LintName: "lexicon-version",
198
-
LintDescription: "unsupported Lexicon language version",
199
-
Message: fmt.Sprintf("found version: %d", sf.Lexicon),
200
-
})
201
-
return issues
202
-
}
203
-
204
-
for defname, def := range sf.Defs {
205
-
defiss := lintSchemaDef(p, nsid, defname, def)
206
-
if len(defiss) > 0 {
207
-
issues = append(issues, defiss...)
208
-
}
209
-
}
210
-
211
-
return issues
212
-
}
213
-
214
-
func lintSchemaDef(p string, nsid syntax.NSID, defname string, def lexicon.SchemaDef) []LintIssue {
215
-
issues := []LintIssue{}
216
-
217
-
// missing description issue, in case it is needed
218
-
missingDesc := func() LintIssue {
219
-
return LintIssue{
220
-
FilePath: p,
221
-
NSID: nsid,
222
-
LintLevel: "warn",
223
-
LintName: "missing-primary-description",
224
-
LintDescription: "primary types (record, query, procedure, subscription, permission-set) should include a description",
225
-
Message: "primary type missing a description",
226
-
}
227
-
}
228
-
229
-
if err := def.CheckSchema(); err != nil {
230
-
issues = append(issues, LintIssue{
231
-
FilePath: p,
232
-
NSID: nsid,
233
-
LintLevel: "error",
234
-
LintName: "lexicon-schema",
235
-
LintDescription: "basic structure schema checks (additional errors may be collapsed)",
236
-
Message: err.Error(),
237
-
})
238
-
}
239
-
240
-
if err := checkSchemaName(defname); err != nil {
241
-
issues = append(issues, LintIssue{
242
-
FilePath: p,
243
-
NSID: nsid,
244
-
LintLevel: "warn",
245
-
LintName: "def-name-syntax",
246
-
LintDescription: "definition name does not follow syntax guidance",
247
-
Message: fmt.Sprintf("%s: %s", err.Error(), defname),
248
-
})
249
-
}
250
-
251
-
if nsid.Name() == "defs" && defname == "main" {
252
-
issues = append(issues, LintIssue{
253
-
FilePath: p,
254
-
NSID: nsid,
255
-
LintLevel: "warn",
256
-
LintName: "defs-main-definition",
257
-
LintDescription: "defs schemas should not have a 'main'",
258
-
Message: "defs schemas should not have a 'main'",
259
-
})
260
-
}
261
-
262
-
switch def.Inner.(type) {
263
-
case lexicon.SchemaRecord, lexicon.SchemaQuery, lexicon.SchemaProcedure, lexicon.SchemaSubscription, lexicon.SchemaPermissionSet:
264
-
if defname != "main" {
265
-
issues = append(issues, LintIssue{
266
-
FilePath: p,
267
-
NSID: nsid,
268
-
LintLevel: "error",
269
-
LintName: "non-main-primary",
270
-
LintDescription: "primary types (record, query, procedure, subscription, permission-set) must be 'main' definition",
271
-
Message: fmt.Sprintf("primary definition types must be 'main': %s", defname),
272
-
})
273
-
}
274
-
}
275
-
276
-
switch v := def.Inner.(type) {
277
-
case lexicon.SchemaRecord:
278
-
if v.Description == nil || *v.Description == "" {
279
-
issues = append(issues, missingDesc())
280
-
}
281
-
reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: v.Record})
282
-
if len(reciss) > 0 {
283
-
issues = append(issues, reciss...)
284
-
}
285
-
case lexicon.SchemaQuery:
286
-
if v.Description == nil || *v.Description == "" {
287
-
issues = append(issues, missingDesc())
288
-
}
289
-
if v.Parameters != nil {
290
-
reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Parameters})
291
-
if len(reciss) > 0 {
292
-
issues = append(issues, reciss...)
293
-
}
294
-
}
295
-
if v.Output == nil {
296
-
issues = append(issues, LintIssue{
297
-
FilePath: p,
298
-
NSID: nsid,
299
-
LintLevel: "warn",
300
-
LintName: "endpoint-output-undefined",
301
-
LintDescription: "API endpoints should define an output (even if empty)",
302
-
Message: "missing output definition",
303
-
})
304
-
} else {
305
-
reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Output})
306
-
if len(reciss) > 0 {
307
-
issues = append(issues, reciss...)
308
-
}
309
-
}
310
-
// TODO: error names
311
-
case lexicon.SchemaProcedure:
312
-
if v.Description == nil || *v.Description == "" {
313
-
issues = append(issues, missingDesc())
314
-
}
315
-
if v.Parameters != nil {
316
-
reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Parameters})
317
-
if len(reciss) > 0 {
318
-
issues = append(issues, reciss...)
319
-
}
320
-
}
321
-
if v.Input != nil {
322
-
reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Input})
323
-
if len(reciss) > 0 {
324
-
issues = append(issues, reciss...)
325
-
}
326
-
}
327
-
if v.Output == nil {
328
-
issues = append(issues, LintIssue{
329
-
FilePath: p,
330
-
NSID: nsid,
331
-
LintLevel: "warn",
332
-
LintName: "endpoint-output-undefined",
333
-
LintDescription: "API endpoints should define an output (even if empty)",
334
-
Message: "missing output definition",
335
-
})
336
-
} else {
337
-
reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Output})
338
-
if len(reciss) > 0 {
339
-
issues = append(issues, reciss...)
340
-
}
341
-
}
342
-
// TODO: error names
343
-
case lexicon.SchemaSubscription:
344
-
if v.Description == nil || *v.Description == "" {
345
-
issues = append(issues, missingDesc())
346
-
}
347
-
if v.Parameters != nil {
348
-
reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Parameters})
349
-
if len(reciss) > 0 {
350
-
issues = append(issues, reciss...)
351
-
}
352
-
}
353
-
if v.Message != nil {
354
-
// TODO: v.Message.Schema must only have local references (same file), and should have at least one defined
355
-
reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: v.Message.Schema})
356
-
if len(reciss) > 0 {
357
-
issues = append(issues, reciss...)
358
-
}
359
-
} else {
360
-
issues = append(issues, LintIssue{
361
-
FilePath: p,
362
-
NSID: nsid,
363
-
LintLevel: "warn",
364
-
LintName: "subscription-no-messages",
365
-
LintDescription: "no subscription message types defined",
366
-
Message: "no subscription message types defined",
367
-
})
368
-
}
369
-
// TODO: at least one message type
370
-
case lexicon.SchemaPermissionSet:
371
-
if v.Title == nil || *v.Title == "" {
372
-
issues = append(issues, LintIssue{
373
-
FilePath: p,
374
-
NSID: nsid,
375
-
LintLevel: "warn",
376
-
LintName: "permissionset-no-title",
377
-
LintDescription: "permission sets should include a title",
378
-
Message: "missing title",
379
-
})
380
-
}
381
-
// TODO: missing detail
382
-
// TODO: translated descriptions?
383
-
if len(v.Permissions) == 0 {
384
-
issues = append(issues, LintIssue{
385
-
FilePath: p,
386
-
NSID: nsid,
387
-
LintLevel: "warn",
388
-
LintName: "permissionset-no-permissions",
389
-
LintDescription: "permission sets should define at least one permission",
390
-
Message: "empty permission set",
391
-
})
392
-
}
393
-
for _, perm := range v.Permissions {
394
-
// TODO: any lints on permissions?
395
-
_ = perm
396
-
}
397
-
case lexicon.SchemaPermission, lexicon.SchemaBoolean, lexicon.SchemaInteger, lexicon.SchemaString, lexicon.SchemaBytes, lexicon.SchemaCIDLink, lexicon.SchemaArray, lexicon.SchemaObject, lexicon.SchemaBlob, lexicon.SchemaToken, lexicon.SchemaRef, lexicon.SchemaUnion, lexicon.SchemaUnknown:
398
-
reciss := lintSchemaRecursive(p, nsid, def)
399
-
if len(reciss) > 0 {
400
-
issues = append(issues, reciss...)
401
-
}
402
-
default:
403
-
slog.Info("no lint rules for top-level schema definition type", "type", fmt.Sprintf("%T", def.Inner))
404
-
}
405
-
return issues
406
-
}
407
-
408
-
func lintSchemaRecursive(p string, nsid syntax.NSID, def lexicon.SchemaDef) []LintIssue {
409
-
issues := []LintIssue{}
410
-
411
-
switch v := def.Inner.(type) {
412
-
case lexicon.SchemaPermission:
413
-
// TODO: anything?
414
-
case lexicon.SchemaBoolean:
415
-
// TODO: default true
416
-
// TODO: both default and const
417
-
case lexicon.SchemaInteger:
418
-
// TODO: both default and const
419
-
case lexicon.SchemaString:
420
-
// TODO: no format and no max length
421
-
// TODO: format and length limits
422
-
// TODO: grapheme limit set, and maxlen either too low or not set
423
-
// TODO: very large max size
424
-
// TODO: format=handle strings within an record type
425
-
case lexicon.SchemaBytes:
426
-
// TODO: very large max size
427
-
case lexicon.SchemaCIDLink:
428
-
// pass
429
-
case lexicon.SchemaArray:
430
-
reciss := lintSchemaRecursive(p, nsid, v.Items)
431
-
if len(reciss) > 0 {
432
-
issues = append(issues, reciss...)
433
-
}
434
-
case lexicon.SchemaObject:
435
-
// NOTE: CheckSchema already verifies that nullable and required are valid against property keys
436
-
for _, propdef := range v.Properties {
437
-
reciss := lintSchemaRecursive(p, nsid, propdef)
438
-
if len(reciss) > 0 {
439
-
issues = append(issues, reciss...)
440
-
}
441
-
// TODO: property name syntax
442
-
}
443
-
for _, k := range v.Nullable {
444
-
if !slices.Contains(v.Required, k) {
445
-
issues = append(issues, LintIssue{
446
-
FilePath: p,
447
-
NSID: nsid,
448
-
LintLevel: "warn",
449
-
LintName: "nullable-and-optional",
450
-
LintDescription: "object properties should not be both optional and nullable",
451
-
Message: fmt.Sprintf("field is both nullable and optional: %s", k),
452
-
})
453
-
}
454
-
}
455
-
case lexicon.SchemaBlob:
456
-
// pass
457
-
case lexicon.SchemaParams:
458
-
// NOTE: CheckSchema already verifies that required are valid against property keys
459
-
for _, propdef := range v.Properties {
460
-
reciss := lintSchemaRecursive(p, nsid, propdef)
461
-
if len(reciss) > 0 {
462
-
issues = append(issues, reciss...)
463
-
}
464
-
// TODO: property name syntax
465
-
}
466
-
case lexicon.SchemaToken:
467
-
// pass
468
-
case lexicon.SchemaRef:
469
-
// TODO: resolve? locally vs globally?
470
-
case lexicon.SchemaUnion:
471
-
// TODO: open vs closed?
472
-
// TODO: check that refs actually resolve?
473
-
case lexicon.SchemaUnknown:
474
-
// pass
475
-
case lexicon.SchemaBody:
476
-
if v.Schema != nil {
477
-
// NOTE: CheckSchema already verified that v.Schema is an object, ref, or union
478
-
reciss := lintSchemaRecursive(p, nsid, *v.Schema)
479
-
if len(reciss) > 0 {
480
-
issues = append(issues, reciss...)
481
-
}
482
-
}
483
-
// TODO: empty/invalid Encoding (mimetype)
484
-
default:
485
-
slog.Info("no lint rules for recursive schema type", "type", fmt.Sprintf("%T", def.Inner), "nsid", nsid)
486
-
}
487
-
488
-
return issues
489
-
}
···
4
"bytes"
5
"context"
6
"encoding/json"
0
7
"fmt"
8
"log/slog"
9
"os"
0
0
10
11
"github.com/bluesky-social/indigo/atproto/lexicon"
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/bnewbold.net/cobalt/cmd/glot/lexlint"
14
15
"github.com/urfave/cli/v3"
16
)
17
18
var (
19
+
ErrLintFailures = lexlint.ErrLintFailures
0
20
)
21
22
var cmdLint = &cli.Command{
···
83
err = sf.FinishParse()
84
}
85
if err != nil {
86
+
iss := lexlint.LintIssue{
87
FilePath: p,
88
//NSID
89
LintLevel: "error",
···
104
return ErrLintFailures
105
}
106
107
+
issues := lexlint.LintSchemaFile(&sf)
108
+
for i := range issues {
109
+
// add path as context
110
+
issues[i].FilePath = p
111
+
}
112
113
// check for unknown fields (more strict, as a lint/warning)
114
var unknownSF lexicon.SchemaFile
115
dec := json.NewDecoder(bytes.NewReader(b))
116
dec.DisallowUnknownFields()
117
if err := dec.Decode(&unknownSF); err != nil {
118
+
issues = append(issues, lexlint.LintIssue{
119
FilePath: p,
120
NSID: syntax.NSID(sf.ID),
121
LintLevel: "warn",
···
148
}
149
return nil
150
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0