go scratch code for atproto

refactor linting code in to lexlint pkg

+736 -712
+2 -364
cmd/glot/breaking.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "log/slog" 8 7 "reflect" 9 - "sort" 10 8 11 9 "github.com/bluesky-social/indigo/atproto/atdata" 12 10 "github.com/bluesky-social/indigo/atproto/lexicon" 13 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/bnewbold.net/cobalt/cmd/glot/lexlint" 14 13 15 14 "github.com/urfave/cli/v3" 16 15 ) ··· 86 85 return err 87 86 } 88 87 89 - issues := breakingMaps(nsid, local.Defs, remote.Defs) 88 + issues := lexlint.BreakingChanges(&remote, &local) 90 89 91 90 if cmd.Bool("json") { 92 91 for _, iss := range issues { ··· 111 110 } 112 111 return nil 113 112 } 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 - }
+376
cmd/glot/lexlint/breaking.go
··· 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
··· 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
··· 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 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 - "errors" 8 7 "fmt" 9 8 "log/slog" 10 9 "os" 11 - "regexp" 12 - "slices" 13 10 14 11 "github.com/bluesky-social/indigo/atproto/lexicon" 15 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/bnewbold.net/cobalt/cmd/glot/lexlint" 16 14 17 15 "github.com/urfave/cli/v3" 18 16 ) 19 17 20 18 var ( 21 - // internal error used to set non-zero return code (but not print separately) 22 - ErrLintFailures = errors.New("linting issues detected") 19 + ErrLintFailures = lexlint.ErrLintFailures 23 20 ) 24 21 25 22 var cmdLint = &cli.Command{ ··· 86 83 err = sf.FinishParse() 87 84 } 88 85 if err != nil { 89 - iss := LintIssue{ 86 + iss := lexlint.LintIssue{ 90 87 FilePath: p, 91 88 //NSID 92 89 LintLevel: "error", ··· 107 104 return ErrLintFailures 108 105 } 109 106 110 - issues := lintSchemaFile(p, sf) 107 + issues := lexlint.LintSchemaFile(&sf) 108 + for i := range issues { 109 + // add path as context 110 + issues[i].FilePath = p 111 + } 111 112 112 113 // check for unknown fields (more strict, as a lint/warning) 113 114 var unknownSF lexicon.SchemaFile 114 115 dec := json.NewDecoder(bytes.NewReader(b)) 115 116 dec.DisallowUnknownFields() 116 117 if err := dec.Decode(&unknownSF); err != nil { 117 - issues = append(issues, LintIssue{ 118 + issues = append(issues, lexlint.LintIssue{ 118 119 FilePath: p, 119 120 NSID: syntax.NSID(sf.ID), 120 121 LintLevel: "warn", ··· 147 148 } 148 149 return nil 149 150 } 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 - }