···
lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
15
-
schemaPath = flag.String("path", "internal/atproto/lexicon", "Path to lexicon schemas directory")
16
-
verbose = flag.Bool("v", false, "Verbose output")
18
+
schemaPath = flag.String("path", "internal/atproto/lexicon", "Path to lexicon schemas directory")
19
+
testDataPath = flag.String("test-data", "tests/lexicon-test-data", "Path to test data directory for ValidateRecord testing")
20
+
verbose = flag.Bool("v", false, "Verbose output")
21
+
strict = flag.Bool("strict", false, "Use strict validation mode")
22
+
schemasOnly = flag.Bool("schemas-only", false, "Only validate schemas, skip test data validation")
···
log.Fatalf("Schema validation failed: %v", err)
45
-
fmt.Println("✅ All schemas validated successfully!")
51
+
// Validate cross-references between schemas
52
+
if err := validateCrossReferences(&catalog, *verbose); err != nil {
53
+
log.Fatalf("Cross-reference validation failed: %v", err)
56
+
// Validate test data unless schemas-only flag is set
58
+
fmt.Printf("\n📋 Validating test data from: %s\n", *testDataPath)
59
+
allSchemas := extractAllSchemaIDs(*schemaPath)
60
+
if err := validateTestData(&catalog, *testDataPath, *verbose, *strict, allSchemas); err != nil {
61
+
log.Fatalf("Test data validation failed: %v", err)
64
+
fmt.Println("\n⏩ Skipping test data validation (--schemas-only flag set)")
67
+
fmt.Println("\n✅ All validations passed successfully!")
// validateSchemaStructure performs additional validation checks
func validateSchemaStructure(catalog *lexicon.BaseCatalog, schemaPath string, verbose bool) error {
var validationErrors []string
74
+
var schemaIDs []string
53
-
// Collect all JSON schema files
76
+
// Collect all JSON schema files and derive their IDs
err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
82
+
// Skip test-data directory
83
+
if info.IsDir() && info.Name() == "test-data" {
84
+
return filepath.SkipDir
// Only process .json files
if !info.IsDir() && filepath.Ext(path) == ".json" {
schemaFiles = append(schemaFiles, path)
91
+
// Convert file path to schema ID
92
+
// e.g., internal/atproto/lexicon/social/coves/actor/profile.json -> social.coves.actor.profile
93
+
relPath, _ := filepath.Rel(schemaPath, path)
94
+
schemaID := filepath.ToSlash(relPath)
95
+
schemaID = schemaID[:len(schemaID)-5] // Remove .json extension
96
+
schemaID = strings.ReplaceAll(schemaID, "/", ".")
97
+
schemaIDs = append(schemaIDs, schemaID)
···
77
-
// Try to resolve some of our expected schemas
78
-
expectedSchemas := []string{
79
-
"social.coves.actor.profile",
80
-
"social.coves.community.profile",
81
-
"social.coves.post.text",
82
-
"social.coves.richtext.facet",
113
+
// Validate all discovered schemas
86
-
fmt.Println("\nValidating key schemas:")
115
+
fmt.Println("\nValidating all schemas:")
89
-
for _, schemaID := range expectedSchemas {
118
+
for i, schemaID := range schemaIDs {
if _, err := catalog.Resolve(schemaID); err != nil {
91
-
validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s: %v", schemaID, err))
120
+
validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s (from %s): %v", schemaID, schemaFiles[i], err))
fmt.Printf(" ✅ %s\n", schemaID)
···
return fmt.Errorf("found %d validation errors", len(validationErrors))
134
+
fmt.Printf("\n✅ Successfully validated all %d schemas\n", len(schemaIDs))
···
148
+
// Skip test-data directory
149
+
if info.IsDir() && info.Name() == "test-data" {
150
+
return filepath.SkipDir
// Only process .json files
if !info.IsDir() && filepath.Ext(path) == ".json" {
schemaFiles = append(schemaFiles, path)
···
// If all individual files loaded OK, try loading the whole directory
return catalog.LoadDirectory(schemaPath)
181
+
// extractAllSchemaIDs walks the schema directory and returns all schema IDs
182
+
func extractAllSchemaIDs(schemaPath string) []string {
183
+
var schemaIDs []string
185
+
filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
190
+
// Skip test-data directory
191
+
if info.IsDir() && info.Name() == "test-data" {
192
+
return filepath.SkipDir
195
+
// Only process .json files
196
+
if !info.IsDir() && filepath.Ext(path) == ".json" {
197
+
// Convert file path to schema ID
198
+
relPath, _ := filepath.Rel(schemaPath, path)
199
+
schemaID := filepath.ToSlash(relPath)
200
+
schemaID = schemaID[:len(schemaID)-5] // Remove .json extension
201
+
schemaID = strings.ReplaceAll(schemaID, "/", ".")
203
+
// Only include record schemas (not procedures)
204
+
if strings.Contains(schemaID, ".record") ||
205
+
strings.Contains(schemaID, ".profile") ||
206
+
strings.Contains(schemaID, ".rules") ||
207
+
strings.Contains(schemaID, ".wiki") ||
208
+
strings.Contains(schemaID, ".subscription") ||
209
+
strings.Contains(schemaID, ".membership") ||
210
+
strings.Contains(schemaID, ".vote") ||
211
+
strings.Contains(schemaID, ".tag") ||
212
+
strings.Contains(schemaID, ".comment") ||
213
+
strings.Contains(schemaID, ".share") ||
214
+
strings.Contains(schemaID, ".tribunalVote") ||
215
+
strings.Contains(schemaID, ".ruleProposal") ||
216
+
strings.Contains(schemaID, ".ban") {
217
+
schemaIDs = append(schemaIDs, schemaID)
226
+
// validateTestData validates test JSON data files against their corresponding schemas
227
+
func validateTestData(catalog *lexicon.BaseCatalog, testDataPath string, verbose bool, strict bool, allSchemas []string) error {
228
+
// Check if test data directory exists
229
+
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
230
+
return fmt.Errorf("test data path does not exist: %s", testDataPath)
233
+
var validationErrors []string
236
+
validSuccessCount := 0
237
+
invalidFailCount := 0
238
+
testedTypes := make(map[string]bool)
240
+
// Walk through test data directory
241
+
err := filepath.Walk(testDataPath, func(path string, info os.FileInfo, err error) error {
246
+
// Only process .json files
247
+
if !info.IsDir() && filepath.Ext(path) == ".json" {
248
+
filename := filepath.Base(path)
249
+
isInvalidTest := strings.Contains(filename, "-invalid-")
253
+
fmt.Printf("\n Testing (expect failure): %s\n", filename)
255
+
fmt.Printf("\n Testing: %s\n", filename)
259
+
// Read the test file
260
+
file, err := os.Open(path)
262
+
validationErrors = append(validationErrors, fmt.Sprintf("Failed to open %s: %v", path, err))
267
+
data, err := io.ReadAll(file)
269
+
validationErrors = append(validationErrors, fmt.Sprintf("Failed to read %s: %v", path, err))
274
+
var recordData map[string]interface{}
275
+
if err := json.Unmarshal(data, &recordData); err != nil {
276
+
validationErrors = append(validationErrors, fmt.Sprintf("Failed to parse JSON in %s: %v", path, err))
280
+
// Extract $type field
281
+
recordType, ok := recordData["$type"].(string)
283
+
validationErrors = append(validationErrors, fmt.Sprintf("Missing or invalid $type field in %s", path))
287
+
// Set validation flags
288
+
flags := lexicon.ValidateFlags(0)
290
+
flags |= lexicon.StrictRecursiveValidation
292
+
flags |= lexicon.AllowLenientDatetime
295
+
// Validate the record
296
+
err = lexicon.ValidateRecord(catalog, recordData, recordType, flags)
299
+
// This file should fail validation
304
+
fmt.Printf(" ✅ Correctly rejected invalid %s record: %v\n", recordType, err)
307
+
validationErrors = append(validationErrors, fmt.Sprintf("Invalid test file %s passed validation when it should have failed", path))
309
+
fmt.Printf(" ❌ ERROR: Invalid record passed validation!\n")
313
+
// This file should pass validation
316
+
validationErrors = append(validationErrors, fmt.Sprintf("Validation failed for %s (type: %s): %v", path, recordType, err))
318
+
fmt.Printf(" ❌ Failed: %v\n", err)
321
+
validSuccessCount++
322
+
testedTypes[recordType] = true
324
+
fmt.Printf(" ✅ Valid %s record\n", recordType)
333
+
return fmt.Errorf("error walking test data directory: %w", err)
336
+
if len(validationErrors) > 0 {
337
+
fmt.Println("\n❌ Test data validation errors found:")
338
+
for _, errMsg := range validationErrors {
339
+
fmt.Printf(" %s\n", errMsg)
341
+
return fmt.Errorf("found %d validation errors", len(validationErrors))
344
+
totalFiles := validFiles + invalidFiles
345
+
if totalFiles == 0 {
346
+
fmt.Println(" ⚠️ No test data files found")
348
+
// Show validation summary
349
+
fmt.Printf("\n📋 Validation Summary:\n")
350
+
fmt.Printf(" Valid test files: %d/%d passed\n", validSuccessCount, validFiles)
351
+
fmt.Printf(" Invalid test files: %d/%d correctly rejected\n", invalidFailCount, invalidFiles)
353
+
if validSuccessCount == validFiles && invalidFailCount == invalidFiles {
354
+
fmt.Printf("\n ✅ All test files behaved as expected!\n")
357
+
// Show test coverage summary (only for valid files)
358
+
fmt.Printf("\n📊 Test Data Coverage Summary:\n")
359
+
fmt.Printf(" - Records with test data: %d types\n", len(testedTypes))
360
+
fmt.Printf(" - Valid test files: %d\n", validFiles)
361
+
fmt.Printf(" - Invalid test files: %d (for error validation)\n", invalidFiles)
363
+
fmt.Printf("\n Tested record types:\n")
364
+
for recordType := range testedTypes {
365
+
fmt.Printf(" ✓ %s\n", recordType)
368
+
// Show untested schemas
370
+
fmt.Printf("\n ⚠️ Record types without test data:\n")
371
+
for _, schema := range allSchemas {
372
+
if !testedTypes[schema] {
373
+
fmt.Printf(" - %s\n", schema)
378
+
if untestedCount == 0 {
379
+
fmt.Println(" (None - full test coverage!)")
381
+
fmt.Printf("\n Coverage: %d/%d record types have test data (%.1f%%)\n",
382
+
len(testedTypes), len(allSchemas),
383
+
float64(len(testedTypes))/float64(len(allSchemas))*100)
389
+
// validateCrossReferences validates that all schema references resolve correctly
390
+
func validateCrossReferences(catalog *lexicon.BaseCatalog, verbose bool) error {
391
+
knownRefs := []string{
392
+
// Rich text facets
393
+
"social.coves.richtext.facet",
394
+
"social.coves.richtext.facet#byteSlice",
395
+
"social.coves.richtext.facet#mention",
396
+
"social.coves.richtext.facet#link",
397
+
"social.coves.richtext.facet#bold",
398
+
"social.coves.richtext.facet#italic",
399
+
"social.coves.richtext.facet#strikethrough",
400
+
"social.coves.richtext.facet#spoiler",
402
+
// Post types and views
403
+
"social.coves.post.get#postView",
404
+
"social.coves.post.get#authorView",
405
+
"social.coves.post.get#communityRef",
406
+
"social.coves.post.get#imageView",
407
+
"social.coves.post.get#videoView",
408
+
"social.coves.post.get#externalView",
409
+
"social.coves.post.get#postStats",
410
+
"social.coves.post.get#viewerState",
412
+
// Post record types
413
+
"social.coves.post.record#originalAuthor",
415
+
// Actor definitions
416
+
"social.coves.actor.profile#geoLocation",
418
+
// Community definitions
419
+
"social.coves.community.rules#rule",
422
+
var errors []string
424
+
fmt.Println("\n🔍 Validating cross-references between schemas:")
427
+
for _, ref := range knownRefs {
428
+
if _, err := catalog.Resolve(ref); err != nil {
429
+
errors = append(errors, fmt.Sprintf("Failed to resolve reference %s: %v", ref, err))
430
+
} else if verbose {
431
+
fmt.Printf(" ✅ %s\n", ref)
435
+
if len(errors) > 0 {
436
+
return fmt.Errorf("cross-reference validation failed:\n%s", strings.Join(errors, "\n"))