An experimental TypeSpec syntax for Lexicon

wip

+2938 -21
+34
SYNTAX.md
··· 736 736 } 737 737 ``` 738 738 739 + ### Empty Union (No Refs) 740 + 741 + **Pattern:** Use `never | unknown` for unions with no model references 742 + 743 + In rare cases, you need an empty union that accepts any type with a `$type` discriminator but has no known refs. Use the `never | unknown` pattern: 744 + 745 + **TypeSpec:** 746 + ```typespec 747 + model SubjectView { 748 + @required type: string; 749 + @required subject: string; 750 + profile?: never | unknown; // Empty union 751 + } 752 + ``` 753 + 754 + **JSON:** 755 + ```json 756 + { 757 + "profile": { 758 + "type": "union", 759 + "refs": [] 760 + } 761 + } 762 + ``` 763 + 764 + **Why `never | unknown`?** 765 + - `never` creates a union type in TypeSpec (single `unknown` is just intrinsic, not a union) 766 + - `never` contributes no refs (it's an empty type) 767 + - `unknown` marks the union as open/extensible 768 + - Result: empty refs array with open union semantics 769 + 770 + **When to use:** Fields that may contain any discriminated type in the future but have no current known types. 771 + 739 772 ### Syntax Summary 740 773 741 774 | Pattern | Syntax | JSON `closed` field | Use Case | 742 775 |---------|--------|---------------------|----------| 743 776 | **Open union (inline)** | `(A \| B \| unknown)` | omitted (false) | Default - allows future variants | 744 777 | **Open union (named)** | `union { A, B, unknown }` | omitted (false) | Named def, reusable | 778 + | **Empty union** | `never \| unknown` | omitted (false) | No current refs, open to future types | 745 779 | **Closed union (named)** | `@closed union { A, B }` | `true` | Fixed set, named def | 746 780 | **Closed union (inline)** | `Closed<A \| B>` | `true` | Fixed set, inline usage | 747 781 | **Single reference** | `SomeType` | N/A (not a union) | Exactly one type, no variants |
+3 -2
package.json
··· 8 8 "test": "pnpm --filter @tlex/emitter test", 9 9 "test:watch": "pnpm --filter @tlex/emitter test:watch", 10 10 "example": "pnpm --filter @tlex/example build", 11 - "validate": "pnpm build && pnpm test" 11 + "validate": "pnpm build && pnpm run validate-lexicons && pnpm test", 12 + "validate-lexicons": "node scripts/validate-lexicons.js" 12 13 }, 13 14 "repository": { 14 15 "type": "git", ··· 26 27 "devDependencies": { 27 28 "typescript": "^5.0.0" 28 29 } 29 - } 30 + }
+4 -4
packages/emitter/src/emitter.ts
··· 412 412 return this.createStringEnumDef(unionType, variants.stringLiterals, prop); 413 413 } 414 414 415 - // Case 2: Model reference union 416 - if (variants.unionRefs.length > 0) { 415 + // Case 2: Model reference union (including empty union with unknown) 416 + if (variants.unionRefs.length > 0 || variants.hasUnknown) { 417 417 return this.createUnionRefDef(unionType, variants, prop); 418 418 } 419 419 420 - // Case 3: Empty or invalid union 421 - if (variants.stringLiterals.length === 0 && !variants.hasUnknown) { 420 + // Case 3: Empty union without unknown 421 + if (variants.stringLiterals.length === 0) { 422 422 this.program.reportDiagnostic({ 423 423 code: "union-empty", 424 424 severity: "error",
+2 -2
packages/emitter/test/scenarios/atproto/input/app/bsky/labeler/defs.tsp
··· 32 32 labels?: com.atproto.label.defs.Label[]; 33 33 34 34 @doc("The set of report reason 'codes' which are in-scope for this service to review and action. These usually align to policy categories. If not defined (distinct from empty array), all reason types are allowed.") 35 - reasonTypes?: com.atproto.moderation.defs.reasonType[]; 35 + reasonTypes?: com.atproto.moderation.defs.ReasonType[]; 36 36 37 37 @doc("The set of subject types (account, record, etc) this service accepts reports on.") 38 - subjectTypes?: com.atproto.moderation.defs.subjectType[]; 38 + subjectTypes?: com.atproto.moderation.defs.SubjectType[]; 39 39 40 40 @doc("Set of record types (collection NSIDs) which can be reported to this service. If not defined (distinct from empty array), default is any record type.") 41 41 subjectCollections?: nsid[];
+2 -2
packages/emitter/test/scenarios/atproto/input/app/bsky/labeler/service.tsp
··· 11 11 @required createdAt: datetime; 12 12 13 13 @doc("The set of report reason 'codes' which are in-scope for this service to review and action. These usually align to policy categories. If not defined (distinct from empty array), all reason types are allowed.") 14 - reasonTypes?: com.atproto.moderation.defs.reasonType[]; 14 + reasonTypes?: com.atproto.moderation.defs.ReasonType[]; 15 15 16 16 @doc("The set of subject types (account, record, etc) this service accepts reports on.") 17 - subjectTypes?: com.atproto.moderation.defs.subjectType[]; 17 + subjectTypes?: com.atproto.moderation.defs.SubjectType[]; 18 18 19 19 @doc("Set of record types (collection NSIDs) which can be reported to this service. If not defined (distinct from empty array), default is any record type.") 20 20 subjectCollections?: nsid[];
+2 -2
packages/emitter/test/scenarios/atproto/input/app/bsky/richtext/facet.tsp
··· 30 30 model ByteSlice { 31 31 @minValue(0) 32 32 @required 33 - byteStart: int32; 33 + byteStart: integer; 34 34 35 35 @minValue(0) 36 36 @required 37 - byteEnd: int32; 37 + byteEnd: integer; 38 38 } 39 39 }
+33
packages/emitter/test/scenarios/atproto/input/app/bsky/unspecced/searchActorsSkeleton.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace app.bsky.unspecced.searchActorsSkeleton { 4 + model BadQueryString {} 5 + 6 + @doc("Backend Actors (profile) search, returns only skeleton.") 7 + @query 8 + @errors(BadQueryString) 9 + op main( 10 + @doc("Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended. For typeahead search, only simple term match is supported, not full syntax.") 11 + q: string, 12 + 13 + @doc("DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.") 14 + viewer?: did, 15 + 16 + @doc("If true, acts as fast/simple 'typeahead' query.") 17 + typeahead?: boolean, 18 + 19 + @minValue(1) 20 + @maxValue(100) 21 + limit?: integer = 25, 22 + 23 + @doc("Optional pagination mechanism; may not necessarily allow scrolling through entire result set.") 24 + cursor?: string 25 + ): { 26 + cursor?: string; 27 + 28 + @doc("Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.") 29 + hitsTotal?: integer; 30 + 31 + @required actors: app.bsky.unspecced.defs.SkeletonSearchActor[]; 32 + }; 33 + }
+61
packages/emitter/test/scenarios/atproto/input/app/bsky/unspecced/searchPostsSkeleton.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + @maxLength(640) 4 + @maxGraphemes(64) 5 + scalar SkeletonSearchTagString extends string; 6 + 7 + namespace app.bsky.unspecced.searchPostsSkeleton { 8 + model BadQueryString {} 9 + 10 + @doc("Backend Posts search, returns only skeleton") 11 + @query 12 + @errors(BadQueryString) 13 + op main( 14 + @doc("Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.") 15 + q: string, 16 + 17 + @doc("Specifies the ranking order of results.") 18 + sort?: "top" | "latest" | string = "latest", 19 + 20 + @doc("Filter results for posts after the indicated datetime (inclusive). Expected to use 'sortAt' timestamp, which may not match 'createdAt'. Can be a datetime, or just an ISO date (YYYY-MM-DD).") 21 + since?: string, 22 + 23 + @doc("Filter results for posts before the indicated datetime (not inclusive). Expected to use 'sortAt' timestamp, which may not match 'createdAt'. Can be a datetime, or just an ISO date (YYY-MM-DD).") 24 + until?: string, 25 + 26 + @doc("Filter to posts which mention the given account. Handles are resolved to DID before query-time. Only matches rich-text facet mentions.") 27 + mentions?: atIdentifier, 28 + 29 + @doc("Filter to posts by the given account. Handles are resolved to DID before query-time.") 30 + author?: atIdentifier, 31 + 32 + @doc("Filter to posts in the given language. Expected to be based on post language field, though server may override language detection.") 33 + lang?: language, 34 + 35 + @doc("Filter to posts with URLs (facet links or embeds) linking to the given domain (hostname). Server may apply hostname normalization.") 36 + domain?: string, 37 + 38 + @doc("Filter to posts with links (facet links or embeds) pointing to this URL. Server may apply URL normalization or fuzzy matching.") 39 + url?: uri, 40 + 41 + @doc("Filter to posts with the given tag (hashtag), based on rich-text facet or tag field. Do not include the hash (#) prefix. Multiple tags can be specified, with 'AND' matching.") 42 + tag?: SkeletonSearchTagString[], 43 + 44 + @doc("DID of the account making the request (not included for public/unauthenticated queries). Used for 'from:me' queries.") 45 + viewer?: did, 46 + 47 + @minValue(1) 48 + @maxValue(100) 49 + limit?: integer = 25, 50 + 51 + @doc("Optional pagination mechanism; may not necessarily allow scrolling through entire result set.") 52 + cursor?: string 53 + ): { 54 + cursor?: string; 55 + 56 + @doc("Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.") 57 + hitsTotal?: integer; 58 + 59 + @required posts: app.bsky.unspecced.defs.SkeletonSearchPost[]; 60 + }; 61 + }
+30
packages/emitter/test/scenarios/atproto/input/app/bsky/unspecced/searchStarterPacksSkeleton.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace app.bsky.unspecced.searchStarterPacksSkeleton { 4 + model BadQueryString {} 5 + 6 + @doc("Backend Starter Pack search, returns only skeleton.") 7 + @query 8 + @errors(BadQueryString) 9 + op main( 10 + @doc("Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.") 11 + q: string, 12 + 13 + @doc("DID of the account making the request (not included for public/unauthenticated queries).") 14 + viewer?: did, 15 + 16 + @minValue(1) 17 + @maxValue(100) 18 + limit?: integer = 25, 19 + 20 + @doc("Optional pagination mechanism; may not necessarily allow scrolling through entire result set.") 21 + cursor?: string 22 + ): { 23 + cursor?: string; 24 + 25 + @doc("Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.") 26 + hitsTotal?: integer; 27 + 28 + @required starterPacks: app.bsky.unspecced.defs.SkeletonSearchStarterPack[]; 29 + }; 30 + }
+1 -1
packages/emitter/test/scenarios/atproto/input/app/bsky/video/defs.tsp
··· 12 12 @doc("Progress within the current processing state.") 13 13 @minValue(0) 14 14 @maxValue(100) 15 - progress?: int32; 15 + progress?: integer; 16 16 17 17 blob?: Blob; 18 18 error?: string;
+12
packages/emitter/test/scenarios/atproto/input/app/bsky/video/uploadVideo.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace app.bsky.video.uploadVideo { 4 + @doc("Upload a video to be processed then stored on the PDS.") 5 + @procedure 6 + op main( 7 + @encoding("video/mp4") 8 + input: void 9 + ): { 10 + @required jobStatus: app.bsky.video.defs.JobStatus; 11 + }; 12 + }
+23
packages/emitter/test/scenarios/atproto/input/com/atproto/label/subscribeLabels.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.label.subscribeLabels { 4 + model FutureCursor {} 5 + 6 + model Labels { 7 + @required seq: integer; 8 + @required labels: com.atproto.label.defs.Label[]; 9 + } 10 + 11 + model Info { 12 + @required name: "OutdatedCursor" | string; 13 + message?: string; 14 + } 15 + 16 + @doc("Subscribe to stream of labels (and negations). Public endpoint implemented by mod services. Uses same sequencing scheme as repo event stream.") 17 + @subscription 18 + @errors(FutureCursor) 19 + op main( 20 + @doc("The last known event seq number to backfill from.") 21 + cursor?: integer 22 + ): (Labels | Info); 23 + }
+11
packages/emitter/test/scenarios/atproto/input/com/atproto/lexicon/schema.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.lexicon.schema { 4 + @doc("Representation of Lexicon schemas themselves, when published as atproto records. Note that the schema language is not defined in Lexicon; this meta schema currently only includes a single version field ('lexicon'). See the atproto specifications for description of the other expected top-level fields ('id', 'defs', etc).") 5 + @record("nsid") 6 + model Main { 7 + @doc("Indicates the 'version' of the Lexicon language. Must be '1' for the current atproto/Lexicon schema system.") 8 + @required 9 + lexicon: integer; 10 + } 11 + }
+2 -2
packages/emitter/test/scenarios/atproto/input/com/atproto/moderation/createReport.tsp
··· 6 6 op main(input: { 7 7 @doc("Indicates the broad category of violation the report is for.") 8 8 @required 9 - reasonType: com.atproto.moderation.defs.reasonType; 9 + reasonType: com.atproto.moderation.defs.ReasonType; 10 10 11 11 @maxGraphemes(2000) 12 12 @maxLength(20000) ··· 23 23 modTool?: ModTool; 24 24 }): { 25 25 @required id: integer; 26 - @required reasonType: com.atproto.moderation.defs.reasonType; 26 + @required reasonType: com.atproto.moderation.defs.ReasonType; 27 27 28 28 @maxGraphemes(2000) 29 29 @maxLength(20000)
+5 -6
packages/emitter/test/scenarios/atproto/input/com/atproto/moderation/defs.tsp
··· 1 1 import "@tlex/emitter"; 2 2 3 3 namespace com.atproto.moderation.defs { 4 - union reasonType { 4 + union ReasonType { 5 5 string, 6 6 7 7 ReasonSpam: "com.atproto.moderation.defs#reasonSpam", ··· 95 95 model ReasonAppeal {} 96 96 97 97 @doc("Tag describing a type of subject that might be reported.") 98 - union subjectType { 98 + union SubjectType { 99 + "account", 100 + "record", 101 + "chat", 99 102 string, 100 - 101 - Account: "account", 102 - Record: "record", 103 - Chat: "chat", 104 103 } 105 104 }
+11
packages/emitter/test/scenarios/atproto/input/com/atproto/temp/addReservedHandle.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.temp.addReservedHandle { 4 + @doc("Add a handle to the set of reserved handles.") 5 + @procedure 6 + op main( 7 + input: { 8 + @required handle: string; 9 + } 10 + ): {}; 11 + }
+44
packages/emitter/test/scenarios/atproto/input/com/atproto/temp/checkHandleAvailability.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.temp.checkHandleAvailability { 4 + @doc("An invalid email was provided.") 5 + model InvalidEmail {} 6 + 7 + @doc("Indicates the provided handle is available.") 8 + model ResultAvailable {} 9 + 10 + @doc("Indicates the provided handle is unavailable and gives suggestions of available handles.") 11 + model ResultUnavailable { 12 + @doc("List of suggested handles based on the provided inputs.") 13 + @required 14 + suggestions: Suggestion[]; 15 + } 16 + 17 + model Suggestion { 18 + @required handle: handle; 19 + 20 + @doc("Method used to build this suggestion. Should be considered opaque to clients. Can be used for metrics.") 21 + @required 22 + method: string; 23 + } 24 + 25 + @doc("Checks whether the provided handle is available. If the handle is not available, available suggestions will be returned. Optional inputs will be used to generate suggestions.") 26 + @query 27 + @errors(InvalidEmail) 28 + op main( 29 + @doc("Tentative handle. Will be checked for availability or used to build handle suggestions.") 30 + handle: handle, 31 + 32 + @doc("User-provided email. Might be used to build handle suggestions.") 33 + email?: string, 34 + 35 + @doc("User-provided birth date. Might be used to build handle suggestions.") 36 + birthDate?: datetime 37 + ): { 38 + @doc("Echo of the input handle.") 39 + @required 40 + handle: handle; 41 + 42 + @required result: ResultAvailable | ResultUnavailable; 43 + }; 44 + }
+11
packages/emitter/test/scenarios/atproto/input/com/atproto/temp/checkSignupQueue.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.temp.checkSignupQueue { 4 + @doc("Check accounts location in signup queue.") 5 + @query 6 + op main(): { 7 + @required activated: boolean; 8 + placeInQueue?: integer; 9 + estimatedTimeMs?: integer; 10 + }; 11 + }
+18
packages/emitter/test/scenarios/atproto/input/com/atproto/temp/dereferenceScope.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.temp.dereferenceScope { 4 + @doc("An invalid scope reference was provided.") 5 + model InvalidScopeReference {} 6 + 7 + @doc("Allows finding the oauth permission scope from a reference") 8 + @query 9 + @errors(InvalidScopeReference) 10 + op main( 11 + @doc("The scope reference (starts with 'ref:')") 12 + scope: string 13 + ): { 14 + @doc("The full oauth permission scope") 15 + @required 16 + scope: string; 17 + }; 18 + }
+15
packages/emitter/test/scenarios/atproto/input/com/atproto/temp/fetchLabels.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.temp.fetchLabels { 4 + @doc("DEPRECATED: use queryLabels or subscribeLabels instead -- Fetch all labels from a labeler created after a certain date.") 5 + @query 6 + op main( 7 + since?: integer, 8 + 9 + @minValue(1) 10 + @maxValue(250) 11 + limit?: integer = 50 12 + ): { 13 + @required labels: com.atproto.label.defs.Label[]; 14 + }; 15 + }
+11
packages/emitter/test/scenarios/atproto/input/com/atproto/temp/requestPhoneVerification.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.temp.requestPhoneVerification { 4 + @doc("Request a verification code to be sent to the supplied phone number") 5 + @procedure 6 + op main( 7 + input: { 8 + @required phoneNumber: string; 9 + } 10 + ): void; 11 + }
+11
packages/emitter/test/scenarios/atproto/input/com/atproto/temp/revokeAccountCredentials.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace com.atproto.temp.revokeAccountCredentials { 4 + @doc("Revoke sessions, password, and app passwords associated with account. May be resolved by a password reset.") 5 + @procedure 6 + op main( 7 + input: { 8 + @required account: atIdentifier; 9 + } 10 + ): void; 11 + }
+30
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/createTemplate.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.communication.createTemplate { 4 + model DuplicateTemplateName {} 5 + 6 + @doc("Administrative action to create a new, re-usable communication (email for now) template.") 7 + @procedure 8 + @errors(DuplicateTemplateName) 9 + op main( 10 + input: { 11 + @doc("Subject of the message, used in emails.") 12 + @required 13 + subject: string; 14 + 15 + @doc("Content of the template, markdown supported, can contain variable placeholders.") 16 + @required 17 + contentMarkdown: string; 18 + 19 + @doc("Name of the template.") 20 + @required 21 + name: string; 22 + 23 + @doc("Message language.") 24 + lang?: language; 25 + 26 + @doc("DID of the user who is creating the template.") 27 + createdBy?: did; 28 + } 29 + ): tools.ozone.communication.defs.TemplateView; 30 + }
+30
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.communication.defs { 4 + model TemplateView { 5 + @required id: string; 6 + 7 + @doc("Name of the template.") 8 + @required 9 + name: string; 10 + 11 + @doc("Content of the template, can contain markdown and variable placeholders.") 12 + subject?: string; 13 + 14 + @doc("Subject of the message, used in emails.") 15 + @required 16 + contentMarkdown: string; 17 + 18 + @required disabled: boolean; 19 + 20 + @doc("Message language.") 21 + lang?: language; 22 + 23 + @doc("DID of the user who last updated the template.") 24 + @required 25 + lastUpdatedBy: did; 26 + 27 + @required createdAt: datetime; 28 + @required updatedAt: datetime; 29 + } 30 + }
+11
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/deleteTemplate.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.communication.deleteTemplate { 4 + @doc("Delete a communication template.") 5 + @procedure 6 + op main( 7 + input: { 8 + @required id: string; 9 + } 10 + ): void; 11 + }
+9
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/listTemplates.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.communication.listTemplates { 4 + @doc("Get list of all communication templates.") 5 + @query 6 + op main(): { 7 + @required communicationTemplates: tools.ozone.communication.defs.TemplateView[]; 8 + }; 9 + }
+33
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/updateTemplate.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.communication.updateTemplate { 4 + model DuplicateTemplateName {} 5 + 6 + @doc("Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.") 7 + @procedure 8 + @errors(DuplicateTemplateName) 9 + op main( 10 + input: { 11 + @doc("ID of the template to be updated.") 12 + @required 13 + id: string; 14 + 15 + @doc("Name of the template.") 16 + name?: string; 17 + 18 + @doc("Message language.") 19 + lang?: language; 20 + 21 + @doc("Content of the template, markdown supported, can contain variable placeholders.") 22 + contentMarkdown?: string; 23 + 24 + @doc("Subject of the message, used in emails.") 25 + subject?: string; 26 + 27 + @doc("DID of the user who is updating the template.") 28 + updatedBy?: did; 29 + 30 + disabled?: boolean; 31 + } 32 + ): tools.ozone.communication.defs.TemplateView; 33 + }
+540
packages/emitter/test/scenarios/atproto/input/tools/ozone/moderation/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.moderation.defs { 4 + model ModEventView { 5 + @required id: integer; 6 + @required event: ModEventTakedown | ModEventReverseTakedown | ModEventComment | ModEventReport | ModEventLabel | ModEventAcknowledge | ModEventEscalate | ModEventMute | ModEventUnmute | ModEventMuteReporter | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert | ModEventTag | AccountEvent | IdentityEvent | RecordEvent | ModEventPriorityScore | AgeAssuranceEvent | AgeAssuranceOverrideEvent | RevokeAccountCredentialsEvent; 7 + @required subject: com.atproto.admin.defs.RepoRef | com.atproto.repo.strongRef.Main | chat.bsky.convo.defs.MessageRef; 8 + @required subjectBlobCids: string[]; 9 + @required createdBy: did; 10 + @required createdAt: datetime; 11 + creatorHandle?: string; 12 + subjectHandle?: string; 13 + modTool?: ModTool; 14 + } 15 + 16 + model ModEventViewDetail { 17 + @required id: integer; 18 + @required event: ModEventTakedown | ModEventReverseTakedown | ModEventComment | ModEventReport | ModEventLabel | ModEventAcknowledge | ModEventEscalate | ModEventMute | ModEventUnmute | ModEventMuteReporter | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert | ModEventTag | AccountEvent | IdentityEvent | RecordEvent | ModEventPriorityScore | AgeAssuranceEvent | AgeAssuranceOverrideEvent | RevokeAccountCredentialsEvent; 19 + @required subject: RepoView | RepoViewNotFound | RecordView | RecordViewNotFound; 20 + @required subjectBlobs: BlobView[]; 21 + @required createdBy: did; 22 + @required createdAt: datetime; 23 + modTool?: ModTool; 24 + } 25 + 26 + model SubjectStatusView { 27 + @required id: integer; 28 + @required subject: com.atproto.admin.defs.RepoRef | com.atproto.repo.strongRef.Main | chat.bsky.convo.defs.MessageRef; 29 + hosting?: AccountHosting | RecordHosting; 30 + subjectBlobCids?: cid[]; 31 + subjectRepoHandle?: string; 32 + 33 + @doc("Timestamp referencing the first moderation status impacting event was emitted on the subject") 34 + @required 35 + createdAt: datetime; 36 + 37 + @doc("Timestamp referencing when the last update was made to the moderation status of the subject") 38 + @required 39 + updatedAt: datetime; 40 + 41 + @required reviewState: SubjectReviewState; 42 + 43 + @doc("Sticky comment on the subject.") 44 + comment?: string; 45 + 46 + @doc("Numeric value representing the level of priority. Higher score means higher priority.") 47 + @minValue(0) 48 + @maxValue(100) 49 + priorityScore?: integer; 50 + 51 + muteUntil?: datetime; 52 + muteReportingUntil?: datetime; 53 + lastReviewedBy?: did; 54 + lastReviewedAt?: datetime; 55 + lastReportedAt?: datetime; 56 + 57 + @doc("Timestamp referencing when the author of the subject appealed a moderation action") 58 + lastAppealedAt?: datetime; 59 + 60 + takendown?: boolean; 61 + 62 + @doc("True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.") 63 + appealed?: boolean; 64 + 65 + suspendUntil?: datetime; 66 + tags?: string[]; 67 + 68 + @doc("Statistics related to the account subject") 69 + accountStats?: AccountStats; 70 + 71 + @doc("Statistics related to the record subjects authored by the subject's account") 72 + recordsStats?: RecordsStats; 73 + 74 + @doc("Current age assurance state of the subject.") 75 + ageAssuranceState?: "pending" | "assured" | "unknown" | "reset" | "blocked" | string; 76 + 77 + @doc("Whether or not the last successful update to age assurance was made by the user or admin.") 78 + ageAssuranceUpdatedBy?: "admin" | "user" | string; 79 + } 80 + 81 + @doc("Detailed view of a subject. For record subjects, the author's repo and profile will be returned.") 82 + model SubjectView { 83 + @required type: com.atproto.moderation.defs.SubjectType; 84 + @required subject: string; 85 + status?: SubjectStatusView; 86 + repo?: RepoViewDetail; 87 + profile?: (never | unknown); 88 + record?: RecordViewDetail; 89 + } 90 + 91 + @doc("Statistics about a particular account subject") 92 + model AccountStats { 93 + @doc("Total number of reports on the account") 94 + reportCount?: integer; 95 + 96 + @doc("Total number of appeals against a moderation action on the account") 97 + appealCount?: integer; 98 + 99 + @doc("Number of times the account was suspended") 100 + suspendCount?: integer; 101 + 102 + @doc("Number of times the account was escalated") 103 + escalateCount?: integer; 104 + 105 + @doc("Number of times the account was taken down") 106 + takedownCount?: integer; 107 + } 108 + 109 + @doc("Statistics about a set of record subject items") 110 + model RecordsStats { 111 + @doc("Cumulative sum of the number of reports on the items in the set") 112 + totalReports?: integer; 113 + 114 + @doc("Number of items that were reported at least once") 115 + reportedCount?: integer; 116 + 117 + @doc("Number of items that were escalated at least once") 118 + escalatedCount?: integer; 119 + 120 + @doc("Number of items that were appealed at least once") 121 + appealedCount?: integer; 122 + 123 + @doc("Total number of item in the set") 124 + subjectCount?: integer; 125 + 126 + @doc("Number of item currently in \"reviewOpen\" or \"reviewEscalated\" state") 127 + pendingCount?: integer; 128 + 129 + @doc("Number of item currently in \"reviewNone\" or \"reviewClosed\" state") 130 + processedCount?: integer; 131 + 132 + @doc("Number of item currently taken down") 133 + takendownCount?: integer; 134 + } 135 + 136 + union SubjectReviewState { 137 + string, 138 + 139 + ReviewOpen: "#reviewOpen", 140 + ReviewEscalated: "#reviewEscalated", 141 + ReviewClosed: "#reviewClosed", 142 + ReviewNone: "#reviewNone", 143 + } 144 + 145 + @doc("Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator") 146 + @token 147 + model ReviewOpen {} 148 + 149 + @doc("Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator") 150 + @token 151 + model ReviewEscalated {} 152 + 153 + @doc("Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator") 154 + @token 155 + model ReviewClosed {} 156 + 157 + @doc("Moderator review status of a subject: Unnecessary. Indicates that the subject does not need a review at the moment but there is probably some moderation related metadata available for it") 158 + @token 159 + model ReviewNone {} 160 + 161 + @doc("Take down a subject permanently or temporarily") 162 + model ModEventTakedown { 163 + comment?: string; 164 + 165 + @doc("Indicates how long the takedown should be in effect before automatically expiring.") 166 + durationInHours?: integer; 167 + 168 + @doc("If true, all other reports on content authored by this account will be resolved (acknowledged).") 169 + acknowledgeAccountSubjects?: boolean; 170 + 171 + @maxItems(5) 172 + @doc("Names/Keywords of the policies that drove the decision.") 173 + policies?: string[]; 174 + } 175 + 176 + @doc("Revert take down action on a subject") 177 + model ModEventReverseTakedown { 178 + @doc("Describe reasoning behind the reversal.") 179 + comment?: string; 180 + } 181 + 182 + @doc("Resolve appeal on a subject") 183 + model ModEventResolveAppeal { 184 + @doc("Describe resolution.") 185 + comment?: string; 186 + } 187 + 188 + @doc("Add a comment to a subject. An empty comment will clear any previously set sticky comment.") 189 + model ModEventComment { 190 + comment?: string; 191 + 192 + @doc("Make the comment persistent on the subject") 193 + sticky?: boolean; 194 + } 195 + 196 + @doc("Report a subject") 197 + model ModEventReport { 198 + comment?: string; 199 + 200 + @doc("Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject.") 201 + isReporterMuted?: boolean; 202 + 203 + @required reportType: com.atproto.moderation.defs.ReasonType; 204 + } 205 + 206 + @doc("Apply/Negate labels on a subject") 207 + model ModEventLabel { 208 + comment?: string; 209 + @required createLabelVals: string[]; 210 + @required negateLabelVals: string[]; 211 + 212 + @doc("Indicates how long the label will remain on the subject. Only applies on labels that are being added.") 213 + durationInHours?: integer; 214 + } 215 + 216 + @doc("Set priority score of the subject. Higher score means higher priority.") 217 + model ModEventPriorityScore { 218 + comment?: string; 219 + 220 + @minValue(0) 221 + @maxValue(100) 222 + @required 223 + score: integer; 224 + } 225 + 226 + @doc("Age assurance info coming directly from users. Only works on DID subjects.") 227 + model AgeAssuranceEvent { 228 + @doc("The date and time of this write operation.") 229 + @required 230 + createdAt: datetime; 231 + 232 + @doc("The status of the age assurance process.") 233 + @required 234 + status: "unknown" | "pending" | "assured" | string; 235 + 236 + @doc("The unique identifier for this instance of the age assurance flow, in UUID format.") 237 + @required 238 + attemptId: string; 239 + 240 + @doc("The IP address used when initiating the AA flow.") 241 + initIp?: string; 242 + 243 + @doc("The user agent used when initiating the AA flow.") 244 + initUa?: string; 245 + 246 + @doc("The IP address used when completing the AA flow.") 247 + completeIp?: string; 248 + 249 + @doc("The user agent used when completing the AA flow.") 250 + completeUa?: string; 251 + } 252 + 253 + @doc("Age assurance status override by moderators. Only works on DID subjects.") 254 + model AgeAssuranceOverrideEvent { 255 + @doc("Comment describing the reason for the override.") 256 + @required 257 + comment: string; 258 + 259 + @doc("The status to be set for the user decided by a moderator, overriding whatever value the user had previously. Use reset to default to original state.") 260 + @required 261 + status: "assured" | "reset" | "blocked" | string; 262 + } 263 + 264 + @doc("Account credentials revocation by moderators. Only works on DID subjects.") 265 + model RevokeAccountCredentialsEvent { 266 + @doc("Comment describing the reason for the revocation.") 267 + @required 268 + comment: string; 269 + } 270 + 271 + model ModEventAcknowledge { 272 + comment?: string; 273 + 274 + @doc("If true, all other reports on content authored by this account will be resolved (acknowledged).") 275 + acknowledgeAccountSubjects?: boolean; 276 + } 277 + 278 + model ModEventEscalate { 279 + comment?: string; 280 + } 281 + 282 + @doc("Mute incoming reports on a subject") 283 + model ModEventMute { 284 + comment?: string; 285 + 286 + @doc("Indicates how long the subject should remain muted.") 287 + @required 288 + durationInHours: integer; 289 + } 290 + 291 + @doc("Unmute action on a subject") 292 + model ModEventUnmute { 293 + @doc("Describe reasoning behind the reversal.") 294 + comment?: string; 295 + } 296 + 297 + @doc("Mute incoming reports from an account") 298 + model ModEventMuteReporter { 299 + comment?: string; 300 + 301 + @doc("Indicates how long the account should remain muted. Falsy value here means a permanent mute.") 302 + durationInHours?: integer; 303 + } 304 + 305 + @doc("Unmute incoming reports from an account") 306 + model ModEventUnmuteReporter { 307 + @doc("Describe reasoning behind the reversal.") 308 + comment?: string; 309 + } 310 + 311 + @doc("Keep a log of outgoing email to a user") 312 + model ModEventEmail { 313 + @doc("The subject line of the email sent to the user.") 314 + @required 315 + subjectLine: string; 316 + 317 + @doc("The content of the email sent to the user.") 318 + content?: string; 319 + 320 + @doc("Additional comment about the outgoing comm.") 321 + comment?: string; 322 + } 323 + 324 + @doc("Divert a record's blobs to a 3rd party service for further scanning/tagging") 325 + model ModEventDivert { 326 + comment?: string; 327 + } 328 + 329 + @doc("Add/Remove a tag on a subject") 330 + model ModEventTag { 331 + @doc("Tags to be added to the subject. If already exists, won't be duplicated.") 332 + @required 333 + add: string[]; 334 + 335 + @doc("Tags to be removed to the subject. Ignores a tag If it doesn't exist, won't be duplicated.") 336 + @required 337 + remove: string[]; 338 + 339 + @doc("Additional comment about added/removed tags.") 340 + comment?: string; 341 + } 342 + 343 + @doc("Logs account status related events on a repo subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.") 344 + model AccountEvent { 345 + comment?: string; 346 + @required timestamp: datetime; 347 + 348 + @doc("Indicates that the account has a repository which can be fetched from the host that emitted this event.") 349 + @required 350 + active: boolean; 351 + 352 + status?: "unknown" | "deactivated" | "deleted" | "takendown" | "suspended" | "tombstoned" | string; 353 + } 354 + 355 + @doc("Logs identity related events on a repo subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.") 356 + model IdentityEvent { 357 + comment?: string; 358 + handle?: handle; 359 + pdsHost?: uri; 360 + tombstone?: boolean; 361 + @required timestamp: datetime; 362 + } 363 + 364 + @doc("Logs lifecycle event on a record subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.") 365 + model RecordEvent { 366 + comment?: string; 367 + @required timestamp: datetime; 368 + 369 + @required 370 + `op`: "create" | "update" | "delete" | string; 371 + 372 + cid?: cid; 373 + } 374 + 375 + model RepoView { 376 + @required did: did; 377 + @required handle: handle; 378 + email?: string; 379 + @required relatedRecords: unknown[]; 380 + @required indexedAt: datetime; 381 + @required moderation: Moderation; 382 + invitedBy?: com.atproto.server.defs.InviteCode; 383 + invitesDisabled?: boolean; 384 + inviteNote?: string; 385 + deactivatedAt?: datetime; 386 + threatSignatures?: com.atproto.admin.defs.ThreatSignature[]; 387 + } 388 + 389 + model RepoViewDetail { 390 + @required did: did; 391 + @required handle: handle; 392 + email?: string; 393 + @required relatedRecords: unknown[]; 394 + @required indexedAt: datetime; 395 + @required moderation: ModerationDetail; 396 + labels?: com.atproto.label.defs.Label[]; 397 + invitedBy?: com.atproto.server.defs.InviteCode; 398 + invites?: com.atproto.server.defs.InviteCode[]; 399 + invitesDisabled?: boolean; 400 + inviteNote?: string; 401 + emailConfirmedAt?: datetime; 402 + deactivatedAt?: datetime; 403 + threatSignatures?: com.atproto.admin.defs.ThreatSignature[]; 404 + } 405 + 406 + model RepoViewNotFound { 407 + @required did: did; 408 + } 409 + 410 + model RecordView { 411 + @required uri: atUri; 412 + @required cid: cid; 413 + @required value: unknown; 414 + @required blobCids: cid[]; 415 + @required indexedAt: datetime; 416 + @required moderation: Moderation; 417 + @required repo: RepoView; 418 + } 419 + 420 + model RecordViewDetail { 421 + @required uri: atUri; 422 + @required cid: cid; 423 + @required value: unknown; 424 + @required blobs: BlobView[]; 425 + labels?: com.atproto.label.defs.Label[]; 426 + @required indexedAt: datetime; 427 + @required moderation: ModerationDetail; 428 + @required repo: RepoView; 429 + } 430 + 431 + model RecordViewNotFound { 432 + @required uri: atUri; 433 + } 434 + 435 + model Moderation { 436 + subjectStatus?: SubjectStatusView; 437 + } 438 + 439 + model ModerationDetail { 440 + subjectStatus?: SubjectStatusView; 441 + } 442 + 443 + model BlobView { 444 + @required cid: cid; 445 + @required mimeType: string; 446 + @required size: integer; 447 + @required createdAt: datetime; 448 + details?: ImageDetails | VideoDetails; 449 + moderation?: Moderation; 450 + } 451 + 452 + model ImageDetails { 453 + @required width: integer; 454 + @required height: integer; 455 + } 456 + 457 + model VideoDetails { 458 + @required width: integer; 459 + @required height: integer; 460 + @required length: integer; 461 + } 462 + 463 + model AccountHosting { 464 + @required 465 + status: "takendown" | "suspended" | "deleted" | "deactivated" | "unknown" | string; 466 + 467 + updatedAt?: datetime; 468 + createdAt?: datetime; 469 + deletedAt?: datetime; 470 + deactivatedAt?: datetime; 471 + reactivatedAt?: datetime; 472 + } 473 + 474 + model RecordHosting { 475 + @required 476 + status: "deleted" | "unknown" | string; 477 + 478 + updatedAt?: datetime; 479 + createdAt?: datetime; 480 + deletedAt?: datetime; 481 + } 482 + 483 + model ReporterStats { 484 + @required did: did; 485 + 486 + @doc("The total number of reports made by the user on accounts.") 487 + @required 488 + accountReportCount: integer; 489 + 490 + @doc("The total number of reports made by the user on records.") 491 + @required 492 + recordReportCount: integer; 493 + 494 + @doc("The total number of accounts reported by the user.") 495 + @required 496 + reportedAccountCount: integer; 497 + 498 + @doc("The total number of records reported by the user.") 499 + @required 500 + reportedRecordCount: integer; 501 + 502 + @doc("The total number of accounts taken down as a result of the user's reports.") 503 + @required 504 + takendownAccountCount: integer; 505 + 506 + @doc("The total number of records taken down as a result of the user's reports.") 507 + @required 508 + takendownRecordCount: integer; 509 + 510 + @doc("The total number of accounts labeled as a result of the user's reports.") 511 + @required 512 + labeledAccountCount: integer; 513 + 514 + @doc("The total number of records labeled as a result of the user's reports.") 515 + @required 516 + labeledRecordCount: integer; 517 + } 518 + 519 + @doc("Moderation tool information for tracing the source of the action") 520 + model ModTool { 521 + @doc("Name/identifier of the source (e.g., 'automod', 'ozone/workspace')") 522 + @required 523 + name: string; 524 + 525 + @doc("Additional arbitrary metadata about the source") 526 + meta?: unknown; 527 + } 528 + 529 + @doc("Moderation event timeline event for a PLC create operation") 530 + @token 531 + model TimelineEventPlcCreate {} 532 + 533 + @doc("Moderation event timeline event for generic PLC operation") 534 + @token 535 + model TimelineEventPlcOperation {} 536 + 537 + @doc("Moderation event timeline event for a PLC tombstone operation") 538 + @token 539 + model TimelineEventPlcTombstone {} 540 + }
+240
packages/emitter/test/scenarios/atproto/input/tools/ozone/report/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.report.defs { 4 + union ReasonType { 5 + string, 6 + 7 + ReasonAppeal: "tools.ozone.report.defs#reasonAppeal", 8 + 9 + ReasonViolenceAnimalWelfare: "tools.ozone.report.defs#reasonViolenceAnimalWelfare", 10 + ReasonViolenceThreats: "tools.ozone.report.defs#reasonViolenceThreats", 11 + ReasonViolenceGraphicContent: "tools.ozone.report.defs#reasonViolenceGraphicContent", 12 + ReasonViolenceSelfHarm: "tools.ozone.report.defs#reasonViolenceSelfHarm", 13 + ReasonViolenceGlorification: "tools.ozone.report.defs#reasonViolenceGlorification", 14 + ReasonViolenceExtremistContent: "tools.ozone.report.defs#reasonViolenceExtremistContent", 15 + ReasonViolenceTrafficking: "tools.ozone.report.defs#reasonViolenceTrafficking", 16 + ReasonViolenceOther: "tools.ozone.report.defs#reasonViolenceOther", 17 + 18 + ReasonSexualAbuseContent: "tools.ozone.report.defs#reasonSexualAbuseContent", 19 + ReasonSexualNCII: "tools.ozone.report.defs#reasonSexualNCII", 20 + ReasonSexualSextortion: "tools.ozone.report.defs#reasonSexualSextortion", 21 + ReasonSexualDeepfake: "tools.ozone.report.defs#reasonSexualDeepfake", 22 + ReasonSexualAnimal: "tools.ozone.report.defs#reasonSexualAnimal", 23 + ReasonSexualUnlabeled: "tools.ozone.report.defs#reasonSexualUnlabeled", 24 + ReasonSexualOther: "tools.ozone.report.defs#reasonSexualOther", 25 + 26 + ReasonChildSafetyCSAM: "tools.ozone.report.defs#reasonChildSafetyCSAM", 27 + ReasonChildSafetyGroom: "tools.ozone.report.defs#reasonChildSafetyGroom", 28 + ReasonChildSafetyMinorPrivacy: "tools.ozone.report.defs#reasonChildSafetyMinorPrivacy", 29 + ReasonChildSafetyEndangerment: "tools.ozone.report.defs#reasonChildSafetyEndangerment", 30 + ReasonChildSafetyHarassment: "tools.ozone.report.defs#reasonChildSafetyHarassment", 31 + ReasonChildSafetyPromotion: "tools.ozone.report.defs#reasonChildSafetyPromotion", 32 + ReasonChildSafetyOther: "tools.ozone.report.defs#reasonChildSafetyOther", 33 + 34 + ReasonHarassmentTroll: "tools.ozone.report.defs#reasonHarassmentTroll", 35 + ReasonHarassmentTargeted: "tools.ozone.report.defs#reasonHarassmentTargeted", 36 + ReasonHarassmentHateSpeech: "tools.ozone.report.defs#reasonHarassmentHateSpeech", 37 + ReasonHarassmentDoxxing: "tools.ozone.report.defs#reasonHarassmentDoxxing", 38 + ReasonHarassmentOther: "tools.ozone.report.defs#reasonHarassmentOther", 39 + 40 + ReasonMisleadingBot: "tools.ozone.report.defs#reasonMisleadingBot", 41 + ReasonMisleadingImpersonation: "tools.ozone.report.defs#reasonMisleadingImpersonation", 42 + ReasonMisleadingSpam: "tools.ozone.report.defs#reasonMisleadingSpam", 43 + ReasonMisleadingScam: "tools.ozone.report.defs#reasonMisleadingScam", 44 + ReasonMisleadingSyntheticContent: "tools.ozone.report.defs#reasonMisleadingSyntheticContent", 45 + ReasonMisleadingMisinformation: "tools.ozone.report.defs#reasonMisleadingMisinformation", 46 + ReasonMisleadingOther: "tools.ozone.report.defs#reasonMisleadingOther", 47 + 48 + ReasonRuleSiteSecurity: "tools.ozone.report.defs#reasonRuleSiteSecurity", 49 + ReasonRuleStolenContent: "tools.ozone.report.defs#reasonRuleStolenContent", 50 + ReasonRuleProhibitedSales: "tools.ozone.report.defs#reasonRuleProhibitedSales", 51 + ReasonRuleBanEvasion: "tools.ozone.report.defs#reasonRuleBanEvasion", 52 + ReasonRuleOther: "tools.ozone.report.defs#reasonRuleOther", 53 + 54 + ReasonCivicElectoralProcess: "tools.ozone.report.defs#reasonCivicElectoralProcess", 55 + ReasonCivicDisclosure: "tools.ozone.report.defs#reasonCivicDisclosure", 56 + ReasonCivicInterference: "tools.ozone.report.defs#reasonCivicInterference", 57 + ReasonCivicMisinformation: "tools.ozone.report.defs#reasonCivicMisinformation", 58 + ReasonCivicImpersonation: "tools.ozone.report.defs#reasonCivicImpersonation", 59 + } 60 + 61 + @doc("Appeal a previously taken moderation action") 62 + @token 63 + model ReasonAppeal {} 64 + 65 + @doc("Animal welfare violations") 66 + @token 67 + model ReasonViolenceAnimalWelfare {} 68 + 69 + @doc("Threats or incitement") 70 + @token 71 + model ReasonViolenceThreats {} 72 + 73 + @doc("Graphic violent content") 74 + @token 75 + model ReasonViolenceGraphicContent {} 76 + 77 + @doc("Self harm") 78 + @token 79 + model ReasonViolenceSelfHarm {} 80 + 81 + @doc("Glorification of violence") 82 + @token 83 + model ReasonViolenceGlorification {} 84 + 85 + @doc("Extremist content. These reports will be sent only be sent to the application's Moderation Authority.") 86 + @token 87 + model ReasonViolenceExtremistContent {} 88 + 89 + @doc("Human trafficking") 90 + @token 91 + model ReasonViolenceTrafficking {} 92 + 93 + @doc("Other violent content") 94 + @token 95 + model ReasonViolenceOther {} 96 + 97 + @doc("Adult sexual abuse content") 98 + @token 99 + model ReasonSexualAbuseContent {} 100 + 101 + @doc("Non-consensual intimate imagery") 102 + @token 103 + model ReasonSexualNCII {} 104 + 105 + @doc("Sextortion") 106 + @token 107 + model ReasonSexualSextortion {} 108 + 109 + @doc("Deepfake adult content") 110 + @token 111 + model ReasonSexualDeepfake {} 112 + 113 + @doc("Animal sexual abuse") 114 + @token 115 + model ReasonSexualAnimal {} 116 + 117 + @doc("Unlabelled adult content") 118 + @token 119 + model ReasonSexualUnlabeled {} 120 + 121 + @doc("Other sexual violence content") 122 + @token 123 + model ReasonSexualOther {} 124 + 125 + @doc("Child sexual abuse material (CSAM). These reports will be sent only be sent to the application's Moderation Authority.") 126 + @token 127 + model ReasonChildSafetyCSAM {} 128 + 129 + @doc("Grooming or predatory behavior. These reports will be sent only be sent to the application's Moderation Authority.") 130 + @token 131 + model ReasonChildSafetyGroom {} 132 + 133 + @doc("Privacy violation involving a minor") 134 + @token 135 + model ReasonChildSafetyMinorPrivacy {} 136 + 137 + @doc("Child endangerment. These reports will be sent only be sent to the application's Moderation Authority.") 138 + @token 139 + model ReasonChildSafetyEndangerment {} 140 + 141 + @doc("Harassment or bullying of minors") 142 + @token 143 + model ReasonChildSafetyHarassment {} 144 + 145 + @doc("Promotion of child exploitation. These reports will be sent only be sent to the application's Moderation Authority.") 146 + @token 147 + model ReasonChildSafetyPromotion {} 148 + 149 + @doc("Other child safety. These reports will be sent only be sent to the application's Moderation Authority.") 150 + @token 151 + model ReasonChildSafetyOther {} 152 + 153 + @doc("Trolling") 154 + @token 155 + model ReasonHarassmentTroll {} 156 + 157 + @doc("Targeted harassment") 158 + @token 159 + model ReasonHarassmentTargeted {} 160 + 161 + @doc("Hate speech") 162 + @token 163 + model ReasonHarassmentHateSpeech {} 164 + 165 + @doc("Doxxing") 166 + @token 167 + model ReasonHarassmentDoxxing {} 168 + 169 + @doc("Other harassing or hateful content") 170 + @token 171 + model ReasonHarassmentOther {} 172 + 173 + @doc("Fake account or bot") 174 + @token 175 + model ReasonMisleadingBot {} 176 + 177 + @doc("Impersonation") 178 + @token 179 + model ReasonMisleadingImpersonation {} 180 + 181 + @doc("Spam") 182 + @token 183 + model ReasonMisleadingSpam {} 184 + 185 + @doc("Scam") 186 + @token 187 + model ReasonMisleadingScam {} 188 + 189 + @doc("Unlabelled gen-AI or synthetic content") 190 + @token 191 + model ReasonMisleadingSyntheticContent {} 192 + 193 + @doc("Harmful false claims") 194 + @token 195 + model ReasonMisleadingMisinformation {} 196 + 197 + @doc("Other misleading content") 198 + @token 199 + model ReasonMisleadingOther {} 200 + 201 + @doc("Hacking or system attacks") 202 + @token 203 + model ReasonRuleSiteSecurity {} 204 + 205 + @doc("Stolen content") 206 + @token 207 + model ReasonRuleStolenContent {} 208 + 209 + @doc("Promoting or selling prohibited items or services") 210 + @token 211 + model ReasonRuleProhibitedSales {} 212 + 213 + @doc("Banned user returning") 214 + @token 215 + model ReasonRuleBanEvasion {} 216 + 217 + @doc("Other") 218 + @token 219 + model ReasonRuleOther {} 220 + 221 + @doc("Electoral process violations") 222 + @token 223 + model ReasonCivicElectoralProcess {} 224 + 225 + @doc("Disclosure & transparency violations") 226 + @token 227 + model ReasonCivicDisclosure {} 228 + 229 + @doc("Voter intimidation or interference") 230 + @token 231 + model ReasonCivicInterference {} 232 + 233 + @doc("Election misinformation") 234 + @token 235 + model ReasonCivicMisinformation {} 236 + 237 + @doc("Impersonation of electoral officials/entities") 238 + @token 239 + model ReasonCivicImpersonation {} 240 + }
+83
packages/emitter/test/scenarios/atproto/input/tools/ozone/safelink/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.safelink.defs { 4 + @doc("An event for URL safety decisions") 5 + model Event { 6 + @doc("Auto-incrementing row ID") 7 + @required 8 + id: integer; 9 + 10 + @required eventType: EventType; 11 + 12 + @doc("The URL that this rule applies to") 13 + @required 14 + url: string; 15 + 16 + @required pattern: PatternType; 17 + @required action: ActionType; 18 + @required reason: ReasonType; 19 + 20 + @doc("DID of the user who created this rule") 21 + @required 22 + createdBy: did; 23 + 24 + @required createdAt: datetime; 25 + 26 + @doc("Optional comment about the decision") 27 + comment?: string; 28 + } 29 + 30 + union EventType { 31 + "addRule", 32 + "updateRule", 33 + "removeRule", 34 + string, 35 + } 36 + 37 + union PatternType { 38 + "domain", 39 + "url", 40 + string, 41 + } 42 + 43 + union ActionType { 44 + "block", 45 + "warn", 46 + "whitelist", 47 + string, 48 + } 49 + 50 + union ReasonType { 51 + "csam", 52 + "spam", 53 + "phishing", 54 + "none", 55 + string, 56 + } 57 + 58 + @doc("Input for creating a URL safety rule") 59 + model UrlRule { 60 + @doc("The URL or domain to apply the rule to") 61 + @required 62 + url: string; 63 + 64 + @required pattern: PatternType; 65 + @required action: ActionType; 66 + @required reason: ReasonType; 67 + 68 + @doc("Optional comment about the decision") 69 + comment?: string; 70 + 71 + @doc("DID of the user added the rule.") 72 + @required 73 + createdBy: did; 74 + 75 + @doc("Timestamp when the rule was created") 76 + @required 77 + createdAt: datetime; 78 + 79 + @doc("Timestamp when the rule was last updated") 80 + @required 81 + updatedAt: datetime; 82 + } 83 + }
+24
packages/emitter/test/scenarios/atproto/input/tools/ozone/server/getConfig.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.server.getConfig { 4 + model ServiceConfig { 5 + url?: uri; 6 + } 7 + 8 + model ViewerConfig { 9 + role?: "tools.ozone.team.defs#roleAdmin" | "tools.ozone.team.defs#roleModerator" | "tools.ozone.team.defs#roleTriage" | "tools.ozone.team.defs#roleVerifier" | string; 10 + } 11 + 12 + @doc("Get details about ozone's server configuration.") 13 + @query 14 + op main(): { 15 + appview?: ServiceConfig; 16 + pds?: ServiceConfig; 17 + blobDivert?: ServiceConfig; 18 + chat?: ServiceConfig; 19 + viewer?: ViewerConfig; 20 + 21 + @doc("The did of the verifier used for verification.") 22 + verifierDid?: did; 23 + }; 24 + }
+29
packages/emitter/test/scenarios/atproto/input/tools/ozone/set/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.set.defs { 4 + model Set { 5 + @minLength(3) 6 + @maxLength(128) 7 + @required 8 + name: string; 9 + 10 + @maxGraphemes(1024) 11 + @maxLength(10240) 12 + description?: string; 13 + } 14 + 15 + model SetView { 16 + @minLength(3) 17 + @maxLength(128) 18 + @required 19 + name: string; 20 + 21 + @maxGraphemes(1024) 22 + @maxLength(10240) 23 + description?: string; 24 + 25 + @required setSize: integer; 26 + @required createdAt: datetime; 27 + @required updatedAt: datetime; 28 + } 29 + }
+24
packages/emitter/test/scenarios/atproto/input/tools/ozone/setting/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.setting.defs { 4 + model Option { 5 + @required key: nsid; 6 + @required value: unknown; 7 + @required did: did; 8 + 9 + @maxGraphemes(1024) 10 + @maxLength(10240) 11 + description?: string; 12 + 13 + createdAt?: datetime; 14 + updatedAt?: datetime; 15 + 16 + managerRole?: "tools.ozone.team.defs#roleModerator" | "tools.ozone.team.defs#roleTriage" | "tools.ozone.team.defs#roleAdmin" | "tools.ozone.team.defs#roleVerifier" | string; 17 + 18 + @required 19 + scope: "instance" | "personal" | string; 20 + 21 + @required createdBy: did; 22 + @required lastUpdatedBy: did; 23 + } 24 + }
+31
packages/emitter/test/scenarios/atproto/input/tools/ozone/team/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.team.defs { 4 + model Member { 5 + @required did: did; 6 + disabled?: boolean; 7 + profile?: app.bsky.actor.defs.ProfileViewDetailed; 8 + createdAt?: datetime; 9 + updatedAt?: datetime; 10 + lastUpdatedBy?: string; 11 + 12 + @required 13 + role: "#roleAdmin" | "#roleModerator" | "#roleTriage" | "#roleVerifier" | string; 14 + } 15 + 16 + @doc("Admin role. Highest level of access, can perform all actions.") 17 + @token 18 + model RoleAdmin {} 19 + 20 + @doc("Moderator role. Can perform most actions.") 21 + @token 22 + model RoleModerator {} 23 + 24 + @doc("Triage role. Mostly intended for monitoring and escalating issues.") 25 + @token 26 + model RoleTriage {} 27 + 28 + @doc("Verifier role. Only allowed to issue verifications.") 29 + @token 30 + model RoleVerifier {} 31 + }
+44
packages/emitter/test/scenarios/atproto/input/tools/ozone/verification/defs.tsp
··· 1 + import "@tlex/emitter"; 2 + 3 + namespace tools.ozone.verification.defs { 4 + @doc("Verification data for the associated subject.") 5 + model VerificationView { 6 + @doc("The user who issued this verification.") 7 + @required 8 + issuer: did; 9 + 10 + @doc("The AT-URI of the verification record.") 11 + @required 12 + uri: atUri; 13 + 14 + @doc("The subject of the verification.") 15 + @required 16 + subject: did; 17 + 18 + @doc("Handle of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current handle matches the one at the time of verifying.") 19 + @required 20 + handle: handle; 21 + 22 + @doc("Display name of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current displayName matches the one at the time of verifying.") 23 + @required 24 + displayName: string; 25 + 26 + @doc("Timestamp when the verification was created.") 27 + @required 28 + createdAt: datetime; 29 + 30 + @doc("Describes the reason for revocation, also indicating that the verification is no longer valid.") 31 + revokeReason?: string; 32 + 33 + @doc("Timestamp when the verification was revoked.") 34 + revokedAt?: datetime; 35 + 36 + @doc("The user who revoked this verification.") 37 + revokedBy?: did; 38 + 39 + subjectProfile?: (never | unknown); 40 + issuerProfile?: (never | unknown); 41 + subjectRepo?: tools.ozone.moderation.defs.RepoViewDetail | tools.ozone.moderation.defs.RepoViewNotFound; 42 + issuerRepo?: tools.ozone.moderation.defs.RepoViewDetail | tools.ozone.moderation.defs.RepoViewNotFound; 43 + } 44 + }
+36
packages/emitter/test/spec/README.md
··· 1 + # Lexicon Spec Test Fixtures 2 + 3 + This directory contains test fixtures organized by ATProto Lexicon specification features, not by real-world examples. 4 + 5 + ## Organization 6 + 7 + Each subdirectory focuses on a specific feature from the [Lexicon spec](https://atproto.com/specs/lexicon): 8 + 9 + - **union/** - Union type features (open, closed, variants) 10 + - **string-constraints/** - String knownValues, enum, formats 11 + - **validation/** - Constraints (min/max length/value, graphemes) 12 + - **primary-types/** - Record, query, procedure, subscription 13 + - **field-types/** - Primitive types and their features 14 + - **refs/** - Reference patterns (local, cross-namespace) 15 + - **nullable/** - Nullable field handling 16 + - **unknown/** - Unknown type usage 17 + - **tokens/** - Token definitions and usage 18 + - **binary/** - Blob and bytes types 19 + - **evolution/** - Schema evolution patterns 20 + 21 + ## Purpose 22 + 23 + Unlike `test/scenarios/atproto/` which tests real ATProto lexicons, these fixtures: 24 + 25 + 1. Test each spec feature in isolation 26 + 2. Demonstrate minimal, focused examples 27 + 3. Serve as documentation for TypeSpec → Lexicon mappings 28 + 4. Catch edge cases and corner cases in the spec 29 + 30 + ## Example Structure 31 + 32 + Each feature directory contains: 33 + - `input/*.tsp` - TypeSpec definitions for that feature 34 + - `output/*.json` - Expected Lexicon JSON output 35 + 36 + Tests should be atomic and focus on one feature at a time.
+39
packages/emitter/test/spec/output/binary-types/blob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.blobs", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Blob type examples", 8 + "properties": { 9 + "simpleBlob": { 10 + "type": "blob", 11 + "description": "Basic blob with no constraints" 12 + }, 13 + "imageBlob": { 14 + "type": "blob", 15 + "accept": ["image/*"], 16 + "maxSize": 5000000, 17 + "description": "Image blob up to 5MB" 18 + }, 19 + "specificImages": { 20 + "type": "blob", 21 + "accept": ["image/png", "image/jpeg", "image/gif"], 22 + "maxSize": 2000000, 23 + "description": "Specific image types up to 2MB" 24 + }, 25 + "videoBlob": { 26 + "type": "blob", 27 + "accept": ["video/mp4", "video/webm"], 28 + "maxSize": 100000000, 29 + "description": "Video blob up to 100MB" 30 + }, 31 + "anyBlob": { 32 + "type": "blob", 33 + "accept": ["*/*"], 34 + "description": "Accept any MIME type" 35 + } 36 + } 37 + } 38 + } 39 + }
+20
packages/emitter/test/spec/output/binary-types/cid-link.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.cidLinks", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "CID link examples", 8 + "properties": { 9 + "contentCid": { 10 + "type": "cid-link", 11 + "description": "A CID link to content" 12 + }, 13 + "previousVersion": { 14 + "type": "cid-link", 15 + "description": "Link to previous version" 16 + } 17 + } 18 + } 19 + } 20 + }
+70
packages/emitter/test/spec/output/container-types/array.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.arrays", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Array container types", 8 + "properties": { 9 + "stringArray": { 10 + "type": "array", 11 + "items": { 12 + "type": "string" 13 + }, 14 + "description": "Array of strings" 15 + }, 16 + "arrayWithLength": { 17 + "type": "array", 18 + "items": { 19 + "type": "integer" 20 + }, 21 + "minLength": 1, 22 + "maxLength": 10, 23 + "description": "Array with size constraints" 24 + }, 25 + "arrayOfRefs": { 26 + "type": "array", 27 + "items": { 28 + "type": "ref", 29 + "ref": "#item" 30 + }, 31 + "description": "Array of object references" 32 + }, 33 + "arrayOfUnion": { 34 + "type": "array", 35 + "items": { 36 + "type": "union", 37 + "refs": ["#typeA", "#typeB"] 38 + }, 39 + "description": "Array of union types" 40 + } 41 + } 42 + }, 43 + "item": { 44 + "type": "object", 45 + "properties": { 46 + "value": { 47 + "type": "string" 48 + } 49 + } 50 + }, 51 + "typeA": { 52 + "type": "object", 53 + "required": ["a"], 54 + "properties": { 55 + "a": { 56 + "type": "string" 57 + } 58 + } 59 + }, 60 + "typeB": { 61 + "type": "object", 62 + "required": ["b"], 63 + "properties": { 64 + "b": { 65 + "type": "integer" 66 + } 67 + } 68 + } 69 + } 70 + }
+42
packages/emitter/test/spec/output/container-types/object.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.objects", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Object with various property configurations", 8 + "required": ["requiredField"], 9 + "nullable": ["nullableField"], 10 + "properties": { 11 + "requiredField": { 12 + "type": "string", 13 + "description": "This field is required" 14 + }, 15 + "optionalField": { 16 + "type": "string", 17 + "description": "This field is optional" 18 + }, 19 + "nullableField": { 20 + "type": "string", 21 + "description": "This field can be null" 22 + }, 23 + "nestedObject": { 24 + "type": "ref", 25 + "ref": "#nested" 26 + } 27 + } 28 + }, 29 + "nested": { 30 + "type": "object", 31 + "required": ["id"], 32 + "properties": { 33 + "id": { 34 + "type": "string" 35 + }, 36 + "label": { 37 + "type": "string" 38 + } 39 + } 40 + } 41 + } 42 + }
+56
packages/emitter/test/spec/output/container-types/params.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.queryParams", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Query with various parameter types", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["requiredParam"], 11 + "properties": { 12 + "requiredParam": { 13 + "type": "string", 14 + "description": "A required query parameter" 15 + }, 16 + "boolParam": { 17 + "type": "boolean", 18 + "description": "Optional boolean parameter" 19 + }, 20 + "intParam": { 21 + "type": "integer", 22 + "description": "Optional integer parameter" 23 + }, 24 + "stringParam": { 25 + "type": "string", 26 + "maxLength": 100, 27 + "description": "Optional string parameter" 28 + }, 29 + "arrayParam": { 30 + "type": "array", 31 + "items": { 32 + "type": "string" 33 + }, 34 + "description": "Array of strings" 35 + }, 36 + "unknownParam": { 37 + "type": "unknown", 38 + "description": "Unknown type parameter" 39 + } 40 + } 41 + }, 42 + "output": { 43 + "encoding": "application/json", 44 + "schema": { 45 + "type": "object", 46 + "required": ["result"], 47 + "properties": { 48 + "result": { 49 + "type": "string" 50 + } 51 + } 52 + } 53 + } 54 + } 55 + } 56 + }
+32
packages/emitter/test/spec/output/field-types/boolean-constraints.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.booleanConstraints", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Boolean field constraints", 8 + "properties": { 9 + "defaultTrue": { 10 + "type": "boolean", 11 + "default": true, 12 + "description": "Boolean with default true" 13 + }, 14 + "defaultFalse": { 15 + "type": "boolean", 16 + "default": false, 17 + "description": "Boolean with default false" 18 + }, 19 + "constTrue": { 20 + "type": "boolean", 21 + "const": true, 22 + "description": "Constant true value" 23 + }, 24 + "constFalse": { 25 + "type": "boolean", 26 + "const": false, 27 + "description": "Constant false value" 28 + } 29 + } 30 + } 31 + } 32 + }
+32
packages/emitter/test/spec/output/field-types/bytes-constraints.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.bytesConstraints", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Bytes field constraints", 8 + "properties": { 9 + "simpleBytes": { 10 + "type": "bytes", 11 + "description": "Basic bytes field" 12 + }, 13 + "withLength": { 14 + "type": "bytes", 15 + "minLength": 1, 16 + "maxLength": 1024, 17 + "description": "Bytes with size constraints" 18 + }, 19 + "withMinOnly": { 20 + "type": "bytes", 21 + "minLength": 32, 22 + "description": "Bytes with minimum size only" 23 + }, 24 + "withMaxOnly": { 25 + "type": "bytes", 26 + "maxLength": 5000000, 27 + "description": "Bytes with maximum size only (5MB)" 28 + } 29 + } 30 + } 31 + } 32 + }
+33
packages/emitter/test/spec/output/field-types/integer-constraints.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.integerConstraints", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Integer field constraints", 8 + "properties": { 9 + "withMinMax": { 10 + "type": "integer", 11 + "minimum": 1, 12 + "maximum": 100, 13 + "description": "Integer between 1 and 100" 14 + }, 15 + "withEnum": { 16 + "type": "integer", 17 + "enum": [1, 2, 3, 5, 8, 13], 18 + "description": "Fibonacci numbers only" 19 + }, 20 + "withDefault": { 21 + "type": "integer", 22 + "default": 42, 23 + "description": "Integer with default value" 24 + }, 25 + "withConst": { 26 + "type": "integer", 27 + "const": 99, 28 + "description": "Constant integer value" 29 + } 30 + } 31 + } 32 + } 33 + }
+32
packages/emitter/test/spec/output/field-types/primitives.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.primitives", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "All primitive field types", 8 + "properties": { 9 + "boolField": { 10 + "type": "boolean", 11 + "description": "A boolean field" 12 + }, 13 + "intField": { 14 + "type": "integer", 15 + "description": "An integer field" 16 + }, 17 + "stringField": { 18 + "type": "string", 19 + "description": "A string field" 20 + }, 21 + "bytesField": { 22 + "type": "bytes", 23 + "description": "A bytes field" 24 + }, 25 + "cidField": { 26 + "type": "cid-link", 27 + "description": "A CID link field" 28 + } 29 + } 30 + } 31 + } 32 + }
+52
packages/emitter/test/spec/output/field-types/string-constraints.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.stringConstraints", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "String field constraints", 8 + "properties": { 9 + "withLength": { 10 + "type": "string", 11 + "minLength": 1, 12 + "maxLength": 100, 13 + "description": "String with byte length constraints" 14 + }, 15 + "withGraphemes": { 16 + "type": "string", 17 + "minGraphemes": 1, 18 + "maxGraphemes": 50, 19 + "description": "String with grapheme cluster length constraints" 20 + }, 21 + "withBothLengths": { 22 + "type": "string", 23 + "minLength": 1, 24 + "maxLength": 300, 25 + "minGraphemes": 1, 26 + "maxGraphemes": 100, 27 + "description": "String with both byte and grapheme constraints" 28 + }, 29 + "withEnum": { 30 + "type": "string", 31 + "enum": ["draft", "published", "archived"], 32 + "description": "Closed enum of allowed values" 33 + }, 34 + "withKnownValues": { 35 + "type": "string", 36 + "knownValues": ["en", "es", "fr", "de", "ja"], 37 + "description": "Open set of suggested values" 38 + }, 39 + "withDefault": { 40 + "type": "string", 41 + "default": "hello", 42 + "description": "String with default value" 43 + }, 44 + "withConst": { 45 + "type": "string", 46 + "const": "fixed-value", 47 + "description": "Constant string value" 48 + } 49 + } 50 + } 51 + } 52 + }
+36
packages/emitter/test/spec/output/meta-types/ref.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.refs", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Reference examples", 8 + "properties": { 9 + "localRef": { 10 + "type": "ref", 11 + "ref": "#localDef", 12 + "description": "Reference to local definition" 13 + }, 14 + "externalRef": { 15 + "type": "ref", 16 + "ref": "com.example.other#someDef", 17 + "description": "Reference to external definition" 18 + }, 19 + "mainRef": { 20 + "type": "ref", 21 + "ref": "com.example.record", 22 + "description": "Reference to main definition (no fragment)" 23 + } 24 + } 25 + }, 26 + "localDef": { 27 + "type": "object", 28 + "required": ["value"], 29 + "properties": { 30 + "value": { 31 + "type": "string" 32 + } 33 + } 34 + } 35 + } 36 + }
+29
packages/emitter/test/spec/output/meta-types/token.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.tokens", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Token usage example", 8 + "required": ["status"], 9 + "properties": { 10 + "status": { 11 + "type": "ref", 12 + "ref": "#active" 13 + } 14 + } 15 + }, 16 + "active": { 17 + "type": "token", 18 + "description": "Indicates an active state" 19 + }, 20 + "inactive": { 21 + "type": "token", 22 + "description": "Indicates an inactive state" 23 + }, 24 + "pending": { 25 + "type": "token", 26 + "description": "Indicates a pending state" 27 + } 28 + } 29 + }
+61
packages/emitter/test/spec/output/meta-types/union-closed.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.unionClosed", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Closed union example", 8 + "required": ["action"], 9 + "properties": { 10 + "action": { 11 + "type": "union", 12 + "refs": ["#create", "#update", "#delete"], 13 + "closed": true, 14 + "description": "Closed union - no more variants allowed" 15 + } 16 + } 17 + }, 18 + "create": { 19 + "type": "object", 20 + "required": ["type", "data"], 21 + "properties": { 22 + "type": { 23 + "type": "string", 24 + "const": "create" 25 + }, 26 + "data": { 27 + "type": "string" 28 + } 29 + } 30 + }, 31 + "update": { 32 + "type": "object", 33 + "required": ["type", "id", "data"], 34 + "properties": { 35 + "type": { 36 + "type": "string", 37 + "const": "update" 38 + }, 39 + "id": { 40 + "type": "string" 41 + }, 42 + "data": { 43 + "type": "string" 44 + } 45 + } 46 + }, 47 + "delete": { 48 + "type": "object", 49 + "required": ["type", "id"], 50 + "properties": { 51 + "type": { 52 + "type": "string", 53 + "const": "delete" 54 + }, 55 + "id": { 56 + "type": "string" 57 + } 58 + } 59 + } 60 + } 61 + }
+17
packages/emitter/test/spec/output/meta-types/union-empty.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.unionEmpty", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Empty open union (similar to unknown but requires $type)", 8 + "properties": { 9 + "data": { 10 + "type": "union", 11 + "refs": [], 12 + "description": "Empty open union - any object with $type allowed" 13 + } 14 + } 15 + } 16 + } 17 + }
+57
packages/emitter/test/spec/output/meta-types/union-open.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.unionOpen", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Open union example (default)", 8 + "required": ["item"], 9 + "properties": { 10 + "item": { 11 + "type": "union", 12 + "refs": ["#typeA", "#typeB", "#typeC"], 13 + "description": "Open union - more variants can be added in future" 14 + } 15 + } 16 + }, 17 + "typeA": { 18 + "type": "object", 19 + "required": ["kind", "valueA"], 20 + "properties": { 21 + "kind": { 22 + "type": "string", 23 + "const": "a" 24 + }, 25 + "valueA": { 26 + "type": "string" 27 + } 28 + } 29 + }, 30 + "typeB": { 31 + "type": "object", 32 + "required": ["kind", "valueB"], 33 + "properties": { 34 + "kind": { 35 + "type": "string", 36 + "const": "b" 37 + }, 38 + "valueB": { 39 + "type": "integer" 40 + } 41 + } 42 + }, 43 + "typeC": { 44 + "type": "object", 45 + "required": ["kind", "valueC"], 46 + "properties": { 47 + "kind": { 48 + "type": "string", 49 + "const": "c" 50 + }, 51 + "valueC": { 52 + "type": "boolean" 53 + } 54 + } 55 + } 56 + } 57 + }
+20
packages/emitter/test/spec/output/meta-types/unknown.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.unknown", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Unknown type usage", 8 + "properties": { 9 + "metadata": { 10 + "type": "unknown", 11 + "description": "Any object data without validation" 12 + }, 13 + "optionalMetadata": { 14 + "type": "unknown", 15 + "description": "Optional unknown field" 16 + } 17 + } 18 + } 19 + } 20 + }
+46
packages/emitter/test/spec/output/primary-types/procedure-simple.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.createRecord", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new record", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["text"], 13 + "properties": { 14 + "text": { 15 + "type": "string", 16 + "description": "The text content" 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["uri", "cid"], 26 + "properties": { 27 + "uri": { 28 + "type": "string", 29 + "format": "at-uri" 30 + }, 31 + "cid": { 32 + "type": "string", 33 + "format": "cid" 34 + } 35 + } 36 + } 37 + }, 38 + "errors": [ 39 + { 40 + "name": "InvalidText", 41 + "description": "The provided text is invalid" 42 + } 43 + ] 44 + } 45 + } 46 + }
+33
packages/emitter/test/spec/output/primary-types/query-simple.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.getRecord", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Retrieve a record by ID", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["id"], 11 + "properties": { 12 + "id": { 13 + "type": "string", 14 + "description": "The record identifier" 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["record"], 23 + "properties": { 24 + "record": { 25 + "type": "ref", 26 + "ref": "com.example.record" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+26
packages/emitter/test/spec/output/primary-types/record-simple.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.record", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A simple record with basic properties", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["text", "createdAt"], 12 + "properties": { 13 + "text": { 14 + "type": "string", 15 + "description": "The text content" 16 + }, 17 + "createdAt": { 18 + "type": "string", 19 + "format": "datetime", 20 + "description": "When the record was created" 21 + } 22 + } 23 + } 24 + } 25 + } 26 + }
+49
packages/emitter/test/spec/output/primary-types/subscription-simple.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.subscribeRecords", 4 + "defs": { 5 + "main": { 6 + "type": "subscription", 7 + "description": "Subscribe to record updates", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "cursor": { 12 + "type": "integer", 13 + "description": "Optional cursor for resuming" 14 + } 15 + } 16 + }, 17 + "message": { 18 + "schema": { 19 + "type": "union", 20 + "refs": ["#record", "#delete"] 21 + } 22 + } 23 + }, 24 + "record": { 25 + "type": "object", 26 + "required": ["uri", "record"], 27 + "properties": { 28 + "uri": { 29 + "type": "string", 30 + "format": "at-uri" 31 + }, 32 + "record": { 33 + "type": "ref", 34 + "ref": "com.example.record" 35 + } 36 + } 37 + }, 38 + "delete": { 39 + "type": "object", 40 + "required": ["uri"], 41 + "properties": { 42 + "uri": { 43 + "type": "string", 44 + "format": "at-uri" 45 + } 46 + } 47 + } 48 + } 49 + }
+41
packages/emitter/test/spec/output/special-features/defs-lexicon.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.defs", 4 + "description": "Common definitions used by other lexicons", 5 + "defs": { 6 + "statusEnum": { 7 + "type": "string", 8 + "enum": ["active", "inactive", "pending", "deleted"] 9 + }, 10 + "timestamp": { 11 + "type": "object", 12 + "required": ["createdAt"], 13 + "nullable": ["updatedAt"], 14 + "properties": { 15 + "createdAt": { 16 + "type": "string", 17 + "format": "datetime" 18 + }, 19 + "updatedAt": { 20 + "type": "string", 21 + "format": "datetime" 22 + } 23 + } 24 + }, 25 + "metadata": { 26 + "type": "object", 27 + "properties": { 28 + "version": { 29 + "type": "integer", 30 + "default": 1 31 + }, 32 + "tags": { 33 + "type": "array", 34 + "items": { 35 + "type": "string" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }
+49
packages/emitter/test/spec/output/special-features/errors.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.withErrors", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Procedure with error definitions", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["value"], 13 + "properties": { 14 + "value": { 15 + "type": "integer", 16 + "minimum": 1, 17 + "maximum": 100 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["result"], 27 + "properties": { 28 + "result": { 29 + "type": "string" 30 + } 31 + } 32 + } 33 + }, 34 + "errors": [ 35 + { 36 + "name": "InvalidValue", 37 + "description": "The provided value is invalid" 38 + }, 39 + { 40 + "name": "RateLimitExceeded", 41 + "description": "Too many requests" 42 + }, 43 + { 44 + "name": "Unauthorized" 45 + } 46 + ] 47 + } 48 + } 49 + }
+46
packages/emitter/test/spec/output/special-features/input-output-ref.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.refInOutput", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Procedure with ref in output schema", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "ref", 12 + "ref": "#inputData" 13 + } 14 + }, 15 + "output": { 16 + "encoding": "application/json", 17 + "schema": { 18 + "type": "ref", 19 + "ref": "#outputData" 20 + } 21 + } 22 + }, 23 + "inputData": { 24 + "type": "object", 25 + "required": ["value"], 26 + "properties": { 27 + "value": { 28 + "type": "string" 29 + } 30 + } 31 + }, 32 + "outputData": { 33 + "type": "object", 34 + "required": ["result", "timestamp"], 35 + "properties": { 36 + "result": { 37 + "type": "string" 38 + }, 39 + "timestamp": { 40 + "type": "string", 41 + "format": "datetime" 42 + } 43 + } 44 + } 45 + } 46 + }
+30
packages/emitter/test/spec/output/special-features/nullable-fields.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.nullableFields", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Demonstrates nullable field semantics", 8 + "required": ["requiredField"], 9 + "nullable": ["nullableRequired", "nullableOptional"], 10 + "properties": { 11 + "requiredField": { 12 + "type": "string", 13 + "description": "Required, cannot be null or omitted" 14 + }, 15 + "nullableRequired": { 16 + "type": "string", 17 + "description": "Must be present, but can be null" 18 + }, 19 + "nullableOptional": { 20 + "type": "string", 21 + "description": "Can be omitted, present with value, or present as null" 22 + }, 23 + "optionalField": { 24 + "type": "string", 25 + "description": "Can be omitted or present with value, but not null" 26 + } 27 + } 28 + } 29 + } 30 + }
+22
packages/emitter/test/spec/output/special-features/output-without-schema.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.outputWithoutSchema", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Query with JSON output but no schema defined", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "id": { 12 + "type": "string" 13 + } 14 + } 15 + }, 16 + "output": { 17 + "encoding": "application/json", 18 + "description": "Returns JSON data without a defined schema" 19 + } 20 + } 21 + } 22 + }
+25
packages/emitter/test/spec/output/special-features/record-key-any.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.flexibleRecord", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record with any key type", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "data": { 13 + "type": "string" 14 + }, 15 + "tags": { 16 + "type": "array", 17 + "items": { 18 + "type": "string" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 + }
+25
packages/emitter/test/spec/output/special-features/record-key-literal.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record with literal 'self' key", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["displayName"], 12 + "properties": { 13 + "displayName": { 14 + "type": "string", 15 + "maxGraphemes": 64 16 + }, 17 + "description": { 18 + "type": "string", 19 + "maxGraphemes": 256 20 + } 21 + } 22 + } 23 + } 24 + } 25 + }
+24
packages/emitter/test/spec/output/special-features/record-key-tid.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.tidRecord", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record with TID key type", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["content", "createdAt"], 12 + "properties": { 13 + "content": { 14 + "type": "string" 15 + }, 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+123
packages/emitter/test/spec/output/special-features/subscription-union-message.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.eventStream", 4 + "defs": { 5 + "main": { 6 + "type": "subscription", 7 + "description": "Subscription with union message schema", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "cursor": { 12 + "type": "integer" 13 + } 14 + } 15 + }, 16 + "message": { 17 + "description": "Stream of events", 18 + "schema": { 19 + "type": "union", 20 + "refs": ["#commit", "#identity", "#account", "#handle", "#tombstone"] 21 + } 22 + }, 23 + "errors": [ 24 + { 25 + "name": "FutureCursor", 26 + "description": "Cursor is in the future" 27 + } 28 + ] 29 + }, 30 + "commit": { 31 + "type": "object", 32 + "required": ["seq", "time", "repo"], 33 + "properties": { 34 + "seq": { 35 + "type": "integer" 36 + }, 37 + "time": { 38 + "type": "string", 39 + "format": "datetime" 40 + }, 41 + "repo": { 42 + "type": "string", 43 + "format": "did" 44 + } 45 + } 46 + }, 47 + "identity": { 48 + "type": "object", 49 + "required": ["seq", "time", "did"], 50 + "properties": { 51 + "seq": { 52 + "type": "integer" 53 + }, 54 + "time": { 55 + "type": "string", 56 + "format": "datetime" 57 + }, 58 + "did": { 59 + "type": "string", 60 + "format": "did" 61 + } 62 + } 63 + }, 64 + "account": { 65 + "type": "object", 66 + "required": ["seq", "time", "did", "active"], 67 + "properties": { 68 + "seq": { 69 + "type": "integer" 70 + }, 71 + "time": { 72 + "type": "string", 73 + "format": "datetime" 74 + }, 75 + "did": { 76 + "type": "string", 77 + "format": "did" 78 + }, 79 + "active": { 80 + "type": "boolean" 81 + } 82 + } 83 + }, 84 + "handle": { 85 + "type": "object", 86 + "required": ["seq", "time", "did", "handle"], 87 + "properties": { 88 + "seq": { 89 + "type": "integer" 90 + }, 91 + "time": { 92 + "type": "string", 93 + "format": "datetime" 94 + }, 95 + "did": { 96 + "type": "string", 97 + "format": "did" 98 + }, 99 + "handle": { 100 + "type": "string", 101 + "format": "handle" 102 + } 103 + } 104 + }, 105 + "tombstone": { 106 + "type": "object", 107 + "required": ["seq", "time", "did"], 108 + "properties": { 109 + "seq": { 110 + "type": "integer" 111 + }, 112 + "time": { 113 + "type": "string", 114 + "format": "datetime" 115 + }, 116 + "did": { 117 + "type": "string", 118 + "format": "did" 119 + } 120 + } 121 + } 122 + } 123 + }
+67
packages/emitter/test/spec/output/string-formats/all-formats.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.stringFormats", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "All string format types", 8 + "properties": { 9 + "atIdentifier": { 10 + "type": "string", 11 + "format": "at-identifier", 12 + "description": "Handle or DID" 13 + }, 14 + "atUri": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "AT-URI format" 18 + }, 19 + "cid": { 20 + "type": "string", 21 + "format": "cid", 22 + "description": "CID in string format" 23 + }, 24 + "datetime": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "ISO 8601 datetime with timezone" 28 + }, 29 + "did": { 30 + "type": "string", 31 + "format": "did", 32 + "description": "DID identifier" 33 + }, 34 + "handle": { 35 + "type": "string", 36 + "format": "handle", 37 + "description": "Handle identifier" 38 + }, 39 + "nsid": { 40 + "type": "string", 41 + "format": "nsid", 42 + "description": "Namespaced identifier" 43 + }, 44 + "tid": { 45 + "type": "string", 46 + "format": "tid", 47 + "description": "Timestamp identifier" 48 + }, 49 + "recordKey": { 50 + "type": "string", 51 + "format": "record-key", 52 + "description": "Record key (any syntax)" 53 + }, 54 + "uri": { 55 + "type": "string", 56 + "format": "uri", 57 + "description": "Generic URI (RFC 3986)" 58 + }, 59 + "language": { 60 + "type": "string", 61 + "format": "language", 62 + "description": "IETF BCP 47 language tag" 63 + } 64 + } 65 + } 66 + } 67 + }
+28
packages/emitter/test/spec/output/string-formats/datetime-examples.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.datetimeExamples", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Datetime format usage examples", 8 + "required": ["createdAt"], 9 + "properties": { 10 + "createdAt": { 11 + "type": "string", 12 + "format": "datetime", 13 + "description": "Required datetime field" 14 + }, 15 + "updatedAt": { 16 + "type": "string", 17 + "format": "datetime", 18 + "description": "Optional datetime field" 19 + }, 20 + "publishedAt": { 21 + "type": "string", 22 + "format": "datetime", 23 + "description": "Optional publish time" 24 + } 25 + } 26 + } 27 + } 28 + }
+37
packages/emitter/test/spec/output/string-formats/identifier-examples.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.identifierExamples", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Identifier format examples", 8 + "properties": { 9 + "did": { 10 + "type": "string", 11 + "format": "did", 12 + "description": "DID identifier (did:plc:, did:web:, etc)" 13 + }, 14 + "handle": { 15 + "type": "string", 16 + "format": "handle", 17 + "description": "Handle identifier (username.bsky.social)" 18 + }, 19 + "atIdentifier": { 20 + "type": "string", 21 + "format": "at-identifier", 22 + "description": "Either a DID or handle" 23 + }, 24 + "cidString": { 25 + "type": "string", 26 + "format": "cid", 27 + "description": "CID as string" 28 + }, 29 + "languageCode": { 30 + "type": "string", 31 + "format": "language", 32 + "description": "BCP 47 language tag" 33 + } 34 + } 35 + } 36 + } 37 + }
+37
packages/emitter/test/spec/output/string-formats/uri-examples.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.uriExamples", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Various URI format examples", 8 + "properties": { 9 + "atUri": { 10 + "type": "string", 11 + "format": "at-uri", 12 + "description": "AT protocol URI" 13 + }, 14 + "genericUri": { 15 + "type": "string", 16 + "format": "uri", 17 + "description": "Generic URI (https, did, ipfs, etc)" 18 + }, 19 + "recordKey": { 20 + "type": "string", 21 + "format": "record-key", 22 + "description": "Record key identifier" 23 + }, 24 + "nsid": { 25 + "type": "string", 26 + "format": "nsid", 27 + "description": "Namespaced identifier" 28 + }, 29 + "tid": { 30 + "type": "string", 31 + "format": "tid", 32 + "description": "Timestamp identifier" 33 + } 34 + } 35 + } 36 + } 37 + }
+92
scripts/validate-lexicons.js
··· 1 + #!/usr/bin/env node 2 + 3 + const fs = require('fs'); 4 + const path = require('path'); 5 + 6 + // Import validation from atproto lexicon package 7 + const atprotoPath = path.join(__dirname, '../../atproto/packages/lexicon'); 8 + const { Lexicons } = require(path.join(atprotoPath, 'dist/index.js')); 9 + 10 + function findJsonFiles(dir, fileList = []) { 11 + const files = fs.readdirSync(dir); 12 + 13 + files.forEach(file => { 14 + // Skip node_modules, tsp-output, and common non-lexicon directories 15 + if (file === 'node_modules' || file === 'tsp-output' || file === 'dist' || file === '.git') { 16 + return; 17 + } 18 + 19 + const filePath = path.join(dir, file); 20 + const stat = fs.statSync(filePath); 21 + 22 + if (stat.isDirectory()) { 23 + findJsonFiles(filePath, fileList); 24 + } else if (file.endsWith('.json') && !file.includes('package') && !file.includes('tsconfig')) { 25 + fileList.push(filePath); 26 + } 27 + }); 28 + 29 + return fileList; 30 + } 31 + 32 + function validateLexicons() { 33 + const packagesDir = path.join(__dirname, '..', 'packages'); 34 + const files = findJsonFiles(packagesDir); 35 + 36 + if (files.length === 0) { 37 + console.log('No lexicon files found'); 38 + return 0; 39 + } 40 + 41 + let errorCount = 0; 42 + let validCount = 0; 43 + let skippedCount = 0; 44 + const seenIds = new Set(); 45 + 46 + for (const filePath of files) { 47 + const relPath = path.relative(path.join(__dirname, '..'), filePath); 48 + 49 + try { 50 + const content = fs.readFileSync(filePath, 'utf-8'); 51 + const json = JSON.parse(content); 52 + 53 + // Skip non-lexicon JSON files (e.g., package.json, tsconfig.json) 54 + if (!json.lexicon || typeof json.lexicon !== 'number') { 55 + continue; 56 + } 57 + 58 + // Skip duplicates (same ID already seen) 59 + if (json.id && seenIds.has(json.id)) { 60 + skippedCount++; 61 + continue; 62 + } 63 + 64 + // Validate individually with a fresh Lexicons instance 65 + // This avoids duplicate ID errors when validating independent lexicons 66 + const lexicons = new Lexicons(); 67 + lexicons.add(json); 68 + 69 + if (json.id) { 70 + seenIds.add(json.id); 71 + } 72 + 73 + validCount++; 74 + console.log(`✓ ${relPath}`); 75 + } catch (error) { 76 + errorCount++; 77 + console.error(`✗ ${relPath}: ${error.message}`); 78 + } 79 + } 80 + 81 + console.log(`\nValidated ${validCount} lexicons: ${validCount} valid, ${errorCount} errors${skippedCount > 0 ? `, ${skippedCount} duplicates skipped` : ''}`); 82 + 83 + return errorCount > 0 ? 1 : 0; 84 + } 85 + 86 + try { 87 + const exitCode = validateLexicons(); 88 + process.exit(exitCode); 89 + } catch (err) { 90 + console.error('Fatal error:', err); 91 + process.exit(1); 92 + }