this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

evolution rules checker

+477
+476
cmd/glot/compat.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "reflect" 9 + "sort" 10 + 11 + "github.com/bluesky-social/indigo/atproto/data" 12 + "github.com/bluesky-social/indigo/atproto/lexicon" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + 15 + "github.com/urfave/cli/v3" 16 + ) 17 + 18 + var cmdCompat = &cli.Command{ 19 + Name: "compat", 20 + Usage: "check lexicon evolution rules", 21 + ArgsUsage: `<file-or-dir>*`, 22 + Flags: []cli.Flag{ 23 + &cli.StringFlag{ 24 + Name: "lexicons-dir", 25 + Value: "./lexicons/", 26 + Usage: "base directory for project Lexicon files", 27 + Sources: cli.EnvVars("LEXICONS_DIR"), 28 + }, 29 + &cli.BoolFlag{ 30 + Name: "json", 31 + Usage: "output structured JSON", 32 + }, 33 + }, 34 + Action: runCompat, 35 + } 36 + 37 + func runCompat(ctx context.Context, cmd *cli.Command) error { 38 + return compareSchemas(ctx, cmd, compatCompare) 39 + } 40 + 41 + func compatCompare(ctx context.Context, cmd *cli.Command, nsid syntax.NSID, localJSON, remoteJSON json.RawMessage) error { 42 + 43 + // skip schemas which aren't in both locations 44 + if localJSON == nil || remoteJSON == nil { 45 + return nil 46 + } 47 + 48 + localData, err := data.UnmarshalJSON(localJSON) 49 + if err != nil { 50 + return err 51 + } 52 + remoteData, err := data.UnmarshalJSON(remoteJSON) 53 + if err != nil { 54 + return err 55 + } 56 + delete(localData, "$type") 57 + delete(remoteData, "$type") 58 + 59 + // skip if rqual 60 + if reflect.DeepEqual(localData, remoteData) { 61 + return nil 62 + } 63 + 64 + // parse as schema files 65 + var local lexicon.SchemaFile 66 + err = json.Unmarshal(localJSON, &local) 67 + if err == nil { 68 + err = local.FinishParse() 69 + } 70 + if err == nil { 71 + err = local.CheckSchema() 72 + } 73 + if err != nil { 74 + return err 75 + } 76 + 77 + var remote lexicon.SchemaFile 78 + err = json.Unmarshal(remoteJSON, &remote) 79 + if err == nil { 80 + err = remote.FinishParse() 81 + } 82 + if err == nil { 83 + err = local.CheckSchema() 84 + } 85 + if err != nil { 86 + return err 87 + } 88 + 89 + issues := compatMaps(nsid, local.Defs, remote.Defs) 90 + 91 + if cmd.Bool("json") { 92 + for _, iss := range issues { 93 + b, err := json.Marshal(iss) 94 + if err != nil { 95 + return nil 96 + } 97 + fmt.Println(string(b)) 98 + } 99 + } else { 100 + if len(issues) == 0 { 101 + fmt.Printf(" 🟢 %s\n", nsid) 102 + } else { 103 + fmt.Printf(" 🟡 %s\n", nsid) 104 + for _, iss := range issues { 105 + fmt.Printf(" [%s]: %s\n", iss.LintName, iss.Message) 106 + } 107 + } 108 + } 109 + if len(issues) > 0 { 110 + return ErrLintFailures 111 + } 112 + return nil 113 + } 114 + 115 + func compatMaps(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 := compatDefs(nsid, k, local, remote) 144 + if len(nestIssues) > 0 { 145 + issues = append(issues, nestIssues...) 146 + } 147 + } 148 + 149 + return issues 150 + } 151 + 152 + func compatDefs(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, compatDefs(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, compatDefs(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, compatDefs(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, compatDefs(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, compatDefs(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, compatDefs(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, compatDefs(nsid, name, *l.Schema, *r.Schema)...) 222 + } 223 + case lexicon.SchemaNull: 224 + // pass 225 + case lexicon.SchemaBoolean: 226 + r := remote.Inner.(lexicon.SchemaBoolean) 227 + // NOTE: default can change safely 228 + if !eqOptBool(l.Const, r.Const) { 229 + issues = append(issues, LintIssue{ 230 + NSID: nsid, 231 + LintLevel: "error", 232 + LintName: "const-value", 233 + LintDescription: "schema const value change", 234 + Message: fmt.Sprintf("const value changed (%s): %v != %v", name, l.Const, r.Const), 235 + }) 236 + } 237 + case lexicon.SchemaInteger: 238 + r := remote.Inner.(lexicon.SchemaInteger) 239 + // NOTE: default can change safely 240 + if !eqOptInt(l.Const, r.Const) { 241 + issues = append(issues, LintIssue{ 242 + NSID: nsid, 243 + LintLevel: "error", 244 + LintName: "const-value", 245 + LintDescription: "schema const value change", 246 + Message: fmt.Sprintf("const value changed (%s): %v != %v", name, l.Const, r.Const), 247 + }) 248 + } 249 + sort.Ints(l.Enum) 250 + sort.Ints(r.Enum) 251 + if !reflect.DeepEqual(l.Enum, r.Enum) { 252 + issues = append(issues, LintIssue{ 253 + NSID: nsid, 254 + LintLevel: "error", 255 + LintName: "enum-values", 256 + LintDescription: "schema enum values change", 257 + Message: fmt.Sprintf("integer enum value changed (%s)", name), 258 + }) 259 + } 260 + if !eqOptInt(l.Minimum, r.Minimum) || !eqOptInt(l.Maximum, r.Maximum) { 261 + issues = append(issues, LintIssue{ 262 + NSID: nsid, 263 + LintLevel: "warn", 264 + LintName: "integer-range", 265 + LintDescription: "schema min/max values change", 266 + Message: fmt.Sprintf("integer min/max values changed (%s)", name), 267 + }) 268 + } 269 + case lexicon.SchemaString: 270 + r := remote.Inner.(lexicon.SchemaString) 271 + // NOTE: default can change safely 272 + if !eqOptString(l.Const, r.Const) { 273 + issues = append(issues, LintIssue{ 274 + NSID: nsid, 275 + LintLevel: "error", 276 + LintName: "const-value", 277 + LintDescription: "schema const value change", 278 + Message: fmt.Sprintf("const value changed (%s)", name), 279 + }) 280 + } 281 + sort.Strings(l.Enum) 282 + sort.Strings(r.Enum) 283 + if !reflect.DeepEqual(l.Enum, r.Enum) { 284 + issues = append(issues, LintIssue{ 285 + NSID: nsid, 286 + LintLevel: "error", 287 + LintName: "enum-values", 288 + LintDescription: "schema enum values change", 289 + Message: fmt.Sprintf("string enum value changed (%s)", name), 290 + }) 291 + } 292 + // NOTE: known values can change safely 293 + if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) || !eqOptInt(l.MinGraphemes, r.MinGraphemes) || !eqOptInt(l.MaxGraphemes, r.MaxGraphemes) { 294 + issues = append(issues, LintIssue{ 295 + NSID: nsid, 296 + LintLevel: "warn", 297 + LintName: "string-length", 298 + LintDescription: "string min/max length change", 299 + Message: fmt.Sprintf("string min/max length change (%s)", name), 300 + }) 301 + } 302 + if !eqOptString(l.Format, r.Format) { 303 + issues = append(issues, LintIssue{ 304 + NSID: nsid, 305 + LintLevel: "error", 306 + LintName: "string-format", 307 + LintDescription: "string format change", 308 + Message: fmt.Sprintf("string format changed (%s)", name), 309 + }) 310 + } 311 + case lexicon.SchemaBytes: 312 + r := remote.Inner.(lexicon.SchemaBytes) 313 + if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) { 314 + issues = append(issues, LintIssue{ 315 + NSID: nsid, 316 + LintLevel: "warn", 317 + LintName: "bytes-length", 318 + LintDescription: "bytes min/max length change", 319 + Message: fmt.Sprintf("bytes min/max length change (%s)", name), 320 + }) 321 + } 322 + case lexicon.SchemaCIDLink: 323 + // pass 324 + case lexicon.SchemaArray: 325 + r := remote.Inner.(lexicon.SchemaArray) 326 + if !eqOptInt(l.MinLength, r.MinLength) || !eqOptInt(l.MaxLength, r.MaxLength) { 327 + issues = append(issues, LintIssue{ 328 + NSID: nsid, 329 + LintLevel: "warn", 330 + LintName: "array-length", 331 + LintDescription: "array min/max length change", 332 + Message: fmt.Sprintf("array min/max length change (%s)", name), 333 + }) 334 + } 335 + issues = append(issues, compatDefs(nsid, name, l.Items, r.Items)...) 336 + case lexicon.SchemaObject: 337 + r := remote.Inner.(lexicon.SchemaObject) 338 + sort.Strings(l.Required) 339 + sort.Strings(r.Required) 340 + if !reflect.DeepEqual(l.Required, r.Required) { 341 + issues = append(issues, LintIssue{ 342 + NSID: nsid, 343 + LintLevel: "error", 344 + LintName: "object-required", 345 + LintDescription: "change in which fields are required", 346 + Message: fmt.Sprintf("required fields change (%s)", name), 347 + }) 348 + } 349 + sort.Strings(l.Nullable) 350 + sort.Strings(r.Nullable) 351 + if !reflect.DeepEqual(l.Nullable, r.Nullable) { 352 + issues = append(issues, LintIssue{ 353 + NSID: nsid, 354 + LintLevel: "error", 355 + LintName: "object-nullable", 356 + LintDescription: "change in which fields are nullable", 357 + Message: fmt.Sprintf("nullable fields change (%s)", name), 358 + }) 359 + } 360 + issues = append(issues, compatMaps(nsid, l.Properties, r.Properties)...) 361 + case lexicon.SchemaBlob: 362 + r := remote.Inner.(lexicon.SchemaBlob) 363 + sort.Strings(l.Accept) 364 + sort.Strings(r.Accept) 365 + if !reflect.DeepEqual(l.Accept, r.Accept) { 366 + // TODO: how strong of a warning should this be? 367 + issues = append(issues, LintIssue{ 368 + NSID: nsid, 369 + LintLevel: "warn", 370 + LintName: "blob-accept", 371 + LintDescription: "change in blob accept (content-type)", 372 + Message: fmt.Sprintf("blob accept change (%s)", name), 373 + }) 374 + } 375 + if !eqOptInt(l.MaxSize, r.MaxSize) { 376 + issues = append(issues, LintIssue{ 377 + NSID: nsid, 378 + LintLevel: "warn", 379 + LintName: "blob-size", 380 + LintDescription: "blob maximum size change", 381 + Message: fmt.Sprintf("blob max size change (%s)", name), 382 + }) 383 + } 384 + case lexicon.SchemaParams: 385 + r := remote.Inner.(lexicon.SchemaParams) 386 + sort.Strings(l.Required) 387 + sort.Strings(r.Required) 388 + if !reflect.DeepEqual(l.Required, r.Required) { 389 + issues = append(issues, LintIssue{ 390 + NSID: nsid, 391 + LintLevel: "error", 392 + LintName: "params-required", 393 + LintDescription: "change in which fields are required", 394 + Message: fmt.Sprintf("required fields change (%s)", name), 395 + }) 396 + } 397 + issues = append(issues, compatMaps(nsid, l.Properties, r.Properties)...) 398 + case lexicon.SchemaToken: 399 + // pass 400 + case lexicon.SchemaRef: 401 + r := remote.Inner.(lexicon.SchemaRef) 402 + if l.Ref != r.Ref { 403 + // NOTE: if the underlying schemas are the same this could be ok in some situations 404 + issues = append(issues, LintIssue{ 405 + NSID: nsid, 406 + LintLevel: "warn", 407 + LintName: "ref-change", 408 + LintDescription: "change in referenced lexicon", 409 + Message: fmt.Sprintf("ref change (%s): %s != %s", name, l.Ref, r.Ref), 410 + }) 411 + } 412 + case lexicon.SchemaUnion: 413 + r := remote.Inner.(lexicon.SchemaUnion) 414 + if !eqOptBool(l.Closed, r.Closed) { 415 + // TODO: going from default to explicit should be ok... 416 + issues = append(issues, LintIssue{ 417 + NSID: nsid, 418 + LintLevel: "error", 419 + LintName: "union-open-closed", 420 + LintDescription: "can't change union between open and closed", 421 + Message: fmt.Sprintf("union open/closed type changed (%s)", name), 422 + }) 423 + } 424 + // TODO: closed union and refs change 425 + if l.Closed != nil && *l.Closed { 426 + sort.Strings(l.Refs) 427 + sort.Strings(r.Refs) 428 + if !reflect.DeepEqual(l.Refs, r.Refs) { 429 + issues = append(issues, LintIssue{ 430 + NSID: nsid, 431 + LintLevel: "error", 432 + LintName: "union-closed-refs", 433 + LintDescription: "closed unions can not have types (refs) change", 434 + Message: fmt.Sprintf("closed union types (refs) changed (%s)", name), 435 + }) 436 + } 437 + } 438 + case lexicon.SchemaUnknown: 439 + // pass 440 + default: 441 + slog.Warn("unhandled schema def type in compat check", "type", reflect.TypeOf(local.Inner)) 442 + } 443 + 444 + return issues 445 + } 446 + 447 + // helper to check if two optional (pointer) integers are equal/consistent 448 + func eqOptInt(a, b *int) bool { 449 + if a == nil { 450 + return b == nil 451 + } 452 + if b == nil { 453 + return false 454 + } 455 + return *a == *b 456 + } 457 + 458 + func eqOptBool(a, b *bool) bool { 459 + if a == nil { 460 + return b == nil 461 + } 462 + if b == nil { 463 + return false 464 + } 465 + return *a == *b 466 + } 467 + 468 + func eqOptString(a, b *string) bool { 469 + if a == nil { 470 + return b == nil 471 + } 472 + if b == nil { 473 + return false 474 + } 475 + return *a == *b 476 + }
+1
cmd/glot/main.go
··· 40 40 cmdPull, 41 41 cmdStatus, 42 42 cmdDiff, 43 + cmdCompat, 43 44 } 44 45 return app.Run(context.Background(), args) 45 46 }