···11+22+glot: AT Lexicon Utility
33+========================
44+55+This is a developer tool for working with Lexicon schemas:
66+77+- code generation for API clients, servers, and record schemas
88+- publishing schemas to and synchronizing from the AT network
99+- diffing, linting, and verifying schema evolution rules
1010+1111+This project is a work in progress (not much is implemented). This will
1212+probably become a stand-alone git repository, and the name is likely to change.
+491
cmd/glot/lint.go
···11+package main
22+33+import (
44+ "bytes"
55+ "context"
66+ "encoding/json"
77+ "errors"
88+ "slices"
99+ "fmt"
1010+ "io/fs"
1111+ "log/slog"
1212+ "os"
1313+ "path"
1414+ "path/filepath"
1515+ "regexp"
1616+1717+ "github.com/bluesky-social/indigo/atproto/lexicon"
1818+ "github.com/bluesky-social/indigo/atproto/syntax"
1919+2020+ "github.com/urfave/cli/v3"
2121+)
2222+2323+var (
2424+ // NOTE: not actually using these? and should replace with library if we did
2525+ ColorGreen = "\033[32m"
2626+ ColorYellow = "\033[33m"
2727+ ColorReset = "\033[0m"
2828+2929+ // internal error used to set non-zero return code (but not print separately)
3030+ ErrLintFailures = errors.New("linting issues detected")
3131+)
3232+3333+var cmdLint = &cli.Command{
3434+ Name: "lint",
3535+ Usage: "check schema style",
3636+ ArgsUsage: `<file-or-dir>*`,
3737+ Flags: []cli.Flag{
3838+ &cli.StringFlag{
3939+ Name: "lexicons-dir",
4040+ Value: "./lexicons/",
4141+ Usage: "base directory for project Lexicon files",
4242+ Sources: cli.EnvVars("LEXICONS_DIR"),
4343+ },
4444+ &cli.BoolFlag{
4545+ Name: "json",
4646+ Usage: "output structured JSON",
4747+ },
4848+ },
4949+ Action: runLint,
5050+}
5151+5252+func runLint(ctx context.Context, cmd *cli.Command) error {
5353+ paths := cmd.Args().Slice()
5454+ if !cmd.Args().Present() {
5555+ paths = []string{cmd.String("lexicons-dir")}
5656+ _, err := os.Stat(paths[0])
5757+ if err != nil {
5858+ return fmt.Errorf("no path arguments specified and default lexicon directory not found\n%w", err)
5959+ }
6060+ }
6161+6262+ // TODO: load up entire directory in to a catalog? or have a "linter" struct?
6363+6464+ slog.Debug("starting lint run")
6565+ anyFailures := false
6666+ for _, p := range paths {
6767+ finfo, err := os.Stat(p)
6868+ if err != nil {
6969+ return fmt.Errorf("failed loading %s: %w", p, err)
7070+ }
7171+ if finfo.IsDir() {
7272+ if err := filepath.WalkDir(p, func(fp string, d fs.DirEntry, err error) error {
7373+ if d.IsDir() || path.Ext(fp) != ".json" {
7474+ return nil
7575+ }
7676+ err = lintFilePath(ctx, cmd, fp)
7777+ if err == ErrLintFailures {
7878+ anyFailures = true
7979+ return nil
8080+ }
8181+ return err
8282+ }); err != nil {
8383+ return err
8484+ }
8585+ continue
8686+ }
8787+ if err := lintFilePath(ctx, cmd, p); err != nil {
8888+ if err == ErrLintFailures {
8989+ anyFailures = true
9090+ } else {
9191+ return err
9292+ }
9393+ }
9494+ }
9595+ if anyFailures {
9696+ return ErrLintFailures
9797+ }
9898+ return nil
9999+}
100100+101101+func lintFilePath(ctx context.Context, cmd *cli.Command, p string) error {
102102+ b, err := os.ReadFile(p)
103103+ if err != nil {
104104+ return err
105105+ }
106106+107107+ // parse file regularly
108108+ // TODO: use json/v2 when available for case-sensitivity
109109+ var sf lexicon.SchemaFile
110110+ if err := json.Unmarshal(b, &sf); err != nil {
111111+ return err
112112+ }
113113+ if err := sf.FinishParse(); err != nil {
114114+ return err
115115+ }
116116+117117+ issues := lintSchemaFile(p, sf)
118118+119119+ // check for unknown fields (more strict, as a lint/warning)
120120+ var unknownSF lexicon.SchemaFile
121121+ dec := json.NewDecoder(bytes.NewReader(b))
122122+ dec.DisallowUnknownFields()
123123+ if err := dec.Decode(&unknownSF); err != nil {
124124+ issues = append(issues, LintIssue{
125125+ FilePath: p,
126126+ NSID: syntax.NSID(sf.ID),
127127+ LintLevel: "warn",
128128+ LintName: "unexpected-field",
129129+ LintDescription: "schema JSON contains unexpected data",
130130+ Message: err.Error(),
131131+ })
132132+ }
133133+134134+ if cmd.Bool("json") {
135135+ for _, iss := range issues {
136136+ b, err := json.Marshal(iss)
137137+ if err != nil {
138138+ return nil
139139+ }
140140+ fmt.Println(string(b))
141141+ }
142142+ } else {
143143+ if len(issues) == 0 {
144144+ fmt.Printf(" 🟢 %s\n", p)
145145+ } else {
146146+ fmt.Printf(" 🟡 %s\n", p)
147147+ for _, iss := range issues {
148148+ fmt.Printf(" [%s]: %s\n", iss.LintName, iss.Message)
149149+ }
150150+ }
151151+ }
152152+ if len(issues) > 0 {
153153+ return ErrLintFailures
154154+ }
155155+ return nil
156156+}
157157+158158+type LintIssue struct {
159159+ FilePath string `json:"file-path,omitempty"`
160160+ NSID syntax.NSID `json:"nsid,omitempty"`
161161+ LintLevel string `json:"lint-level,omitempty"`
162162+ LintName string `json:"lint-name,omitempty"`
163163+ LintDescription string `json:"lint-description,omitempty"`
164164+ Message string `json:"message,omitempty"`
165165+}
166166+167167+var schemaNameRegex = regexp.MustCompile(`^[a-zA-Z]([a-zA-Z0-9]{0,255})?$`)
168168+169169+func checkSchemaName(raw string) error {
170170+ if raw == "" {
171171+ return errors.New("empty name")
172172+ }
173173+ if len(raw) > 255 {
174174+ return errors.New("name is too long (255 chars max)")
175175+ }
176176+ if !schemaNameRegex.MatchString(raw) {
177177+ return errors.New("name doesn't match recommended syntax/characters")
178178+ }
179179+ return nil
180180+}
181181+182182+183183+func lintSchemaFile(p string, sf lexicon.SchemaFile) []LintIssue {
184184+ issues := []LintIssue{}
185185+186186+ nsid, err := syntax.ParseNSID(sf.ID)
187187+ if err != nil {
188188+ issues = append(issues, LintIssue{
189189+ FilePath: p,
190190+ NSID: syntax.NSID(sf.ID),
191191+ LintLevel: "error",
192192+ LintName: "invalid-nsid",
193193+ LintDescription: "schema file declares NSID with invalid syntax",
194194+ Message: fmt.Sprintf("NSID string: %s", sf.ID),
195195+ })
196196+ }
197197+ if nsid == "" {
198198+ nsid = syntax.NSID(sf.ID)
199199+ }
200200+ if sf.Lexicon != 1 {
201201+ issues = append(issues, LintIssue{
202202+ FilePath: p,
203203+ NSID: nsid,
204204+ LintLevel: "error",
205205+ LintName: "lexicon-version",
206206+ LintDescription: "unsupported Lexicon language version",
207207+ Message: fmt.Sprintf("found version: %d", sf.Lexicon),
208208+ })
209209+ return issues
210210+ }
211211+212212+ for defname, def := range sf.Defs {
213213+ defiss := lintSchemaDef(p, nsid, defname, def)
214214+ if len(defiss) > 0 {
215215+ issues = append(issues, defiss...)
216216+ }
217217+ }
218218+219219+ return issues
220220+}
221221+222222+func lintSchemaDef(p string, nsid syntax.NSID, defname string, def lexicon.SchemaDef) []LintIssue {
223223+ issues := []LintIssue{}
224224+225225+ // missing description issue, in case it is needed
226226+ missingDesc := func() LintIssue {
227227+ return LintIssue{
228228+ FilePath: p,
229229+ NSID: nsid,
230230+ LintLevel: "warn",
231231+ LintName: "missing-primary-description",
232232+ LintDescription: "primary types (record, query, procedure, subscription, permission-set) should include a description",
233233+ Message: "primary type missing a description",
234234+ }
235235+ }
236236+237237+ if err := def.CheckSchema(); err != nil {
238238+ issues = append(issues, LintIssue{
239239+ FilePath: p,
240240+ NSID: nsid,
241241+ LintLevel: "error",
242242+ LintName: "lexicon-schema",
243243+ LintDescription: "basic structure schema checks (additional errors may be collapsed)",
244244+ Message: err.Error(),
245245+ })
246246+ }
247247+248248+ if err := checkSchemaName(defname); err != nil {
249249+ issues = append(issues, LintIssue{
250250+ FilePath: p,
251251+ NSID: nsid,
252252+ LintLevel: "warn",
253253+ LintName: "def-name-syntax",
254254+ LintDescription: "definition name does not follow syntax guidance",
255255+ Message: fmt.Sprintf("%s: %s", err.Error(), defname),
256256+ })
257257+ }
258258+259259+ if nsid.Name() == "defs" && defname == "main" {
260260+ issues = append(issues, LintIssue{
261261+ FilePath: p,
262262+ NSID: nsid,
263263+ LintLevel: "warn",
264264+ LintName: "defs-main-definition",
265265+ LintDescription: "defs schemas should not have a 'main'",
266266+ Message: "defs schemas should not have a 'main'",
267267+ })
268268+ }
269269+270270+ switch def.Inner.(type) {
271271+ case lexicon.SchemaRecord, lexicon.SchemaQuery, lexicon.SchemaProcedure, lexicon.SchemaSubscription, lexicon.SchemaPermissionSet:
272272+ if defname != "main" {
273273+ issues = append(issues, LintIssue{
274274+ FilePath: p,
275275+ NSID: nsid,
276276+ LintLevel: "error",
277277+ LintName: "non-main-primary",
278278+ LintDescription: "primary types (record, query, procedure, subscription, permission-set) must be 'main' definition",
279279+ Message: fmt.Sprintf("primary definition types must be 'main': %s", defname),
280280+ })
281281+ }
282282+ }
283283+284284+ switch v := def.Inner.(type) {
285285+ case lexicon.SchemaRecord:
286286+ if v.Description == nil || *v.Description == "" {
287287+ issues = append(issues, missingDesc())
288288+ }
289289+ reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: v.Record})
290290+ if len(reciss) > 0 {
291291+ issues = append(issues, reciss...)
292292+ }
293293+ case lexicon.SchemaQuery:
294294+ if v.Description == nil || *v.Description == "" {
295295+ issues = append(issues, missingDesc())
296296+ }
297297+ if v.Parameters != nil {
298298+ reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Parameters})
299299+ if len(reciss) > 0 {
300300+ issues = append(issues, reciss...)
301301+ }
302302+ }
303303+ if v.Output == nil {
304304+ issues = append(issues, LintIssue{
305305+ FilePath: p,
306306+ NSID: nsid,
307307+ LintLevel: "warn",
308308+ LintName: "endpoint-output-undefined",
309309+ LintDescription: "API endpoints should define an output (even if empty)",
310310+ Message: "missing output definition",
311311+ })
312312+ } else {
313313+ reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Output})
314314+ if len(reciss) > 0 {
315315+ issues = append(issues, reciss...)
316316+ }
317317+ }
318318+ // TODO: error names
319319+ case lexicon.SchemaProcedure:
320320+ if v.Description == nil || *v.Description == "" {
321321+ issues = append(issues, missingDesc())
322322+ }
323323+ if v.Parameters != nil {
324324+ reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Parameters})
325325+ if len(reciss) > 0 {
326326+ issues = append(issues, reciss...)
327327+ }
328328+ }
329329+ if v.Input != nil {
330330+ reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Input})
331331+ if len(reciss) > 0 {
332332+ issues = append(issues, reciss...)
333333+ }
334334+ }
335335+ if v.Output == nil {
336336+ issues = append(issues, LintIssue{
337337+ FilePath: p,
338338+ NSID: nsid,
339339+ LintLevel: "warn",
340340+ LintName: "endpoint-output-undefined",
341341+ LintDescription: "API endpoints should define an output (even if empty)",
342342+ Message: "missing output definition",
343343+ })
344344+ } else {
345345+ reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Output})
346346+ if len(reciss) > 0 {
347347+ issues = append(issues, reciss...)
348348+ }
349349+ }
350350+ // TODO: error names
351351+ case lexicon.SchemaSubscription:
352352+ if v.Description == nil || *v.Description == "" {
353353+ issues = append(issues, missingDesc())
354354+ }
355355+ if v.Parameters != nil {
356356+ reciss := lintSchemaRecursive(p, nsid, lexicon.SchemaDef{Inner: *v.Parameters})
357357+ if len(reciss) > 0 {
358358+ issues = append(issues, reciss...)
359359+ }
360360+ }
361361+ if v.Message != nil {
362362+ // TODO: v.Message.Schema must be a union (CheckSchema verified this). and must only have local references (same file), and should have at least one defined
363363+ reciss := lintSchemaRecursive(p, nsid, v.Message.Schema)
364364+ if len(reciss) > 0 {
365365+ issues = append(issues, reciss...)
366366+ }
367367+ } else {
368368+ issues = append(issues, LintIssue{
369369+ FilePath: p,
370370+ NSID: nsid,
371371+ LintLevel: "warn",
372372+ LintName: "subscription-no-messages",
373373+ LintDescription: "no subscription message types defined",
374374+ Message: "no subscription message types defined",
375375+ })
376376+ }
377377+ // TODO: at least one message type
378378+ case lexicon.SchemaPermissionSet:
379379+ if v.Description == nil || *v.Description == "" {
380380+ issues = append(issues, missingDesc())
381381+ }
382382+ // TODO: translated descriptions?
383383+ if len(v.Permissions) == 0 {
384384+ issues = append(issues, LintIssue{
385385+ FilePath: p,
386386+ NSID: nsid,
387387+ LintLevel: "warn",
388388+ LintName: "permissions-no-members",
389389+ LintDescription: "permission sets should define at least one permission",
390390+ Message: "empty permission set",
391391+ })
392392+ }
393393+ for _, perm := range v.Permissions {
394394+ // TODO: any lints on permissions?
395395+ _ = perm
396396+ }
397397+ case lexicon.SchemaPermission, lexicon.SchemaNull, lexicon.SchemaBoolean, lexicon.SchemaInteger, lexicon.SchemaString, lexicon.SchemaBytes, lexicon.SchemaCIDLink, lexicon.SchemaArray, lexicon.SchemaObject, lexicon.SchemaBlob, lexicon.SchemaToken, lexicon.SchemaRef, lexicon.SchemaUnion, lexicon.SchemaUnknown:
398398+ reciss := lintSchemaRecursive(p, nsid, def)
399399+ if len(reciss) > 0 {
400400+ issues = append(issues, reciss...)
401401+ }
402402+ default:
403403+ slog.Info("no lint rules for top-level schema definition type", "type", fmt.Sprintf("%T", def.Inner))
404404+ }
405405+ return issues
406406+}
407407+408408+func lintSchemaRecursive(p string, nsid syntax.NSID, def lexicon.SchemaDef) []LintIssue {
409409+ issues := []LintIssue{}
410410+411411+ switch v := def.Inner.(type) {
412412+ case lexicon.SchemaPermission:
413413+ // TODO:
414414+ case lexicon.SchemaNull:
415415+ // pass
416416+ case lexicon.SchemaBoolean:
417417+ // TODO: default true
418418+ // TODO: both default and const
419419+ case lexicon.SchemaInteger:
420420+ // TODO: both default and const
421421+ case lexicon.SchemaString:
422422+ // TODO: no format and no max length
423423+ // TODO: format and length limits
424424+ // TODO: grapheme limit set, and maxlen either too low or not set
425425+ // TODO: very large max size
426426+ // TODO: format=handle strings within an record type
427427+ case lexicon.SchemaBytes:
428428+ // TODO: very large max size
429429+ case lexicon.SchemaCIDLink:
430430+ // pass
431431+ case lexicon.SchemaArray:
432432+ reciss := lintSchemaRecursive(p, nsid, v.Items)
433433+ if len(reciss) > 0 {
434434+ issues = append(issues, reciss...)
435435+ }
436436+ case lexicon.SchemaObject:
437437+ // NOTE: CheckSchema already verifies that nullable and required are valid against property keys
438438+ for _, propdef := range v.Properties {
439439+ reciss := lintSchemaRecursive(p, nsid, propdef)
440440+ if len(reciss) > 0 {
441441+ issues = append(issues, reciss...)
442442+ }
443443+ // TODO: property name syntax
444444+ }
445445+ for _, k := range v.Nullable {
446446+ if !slices.Contains(v.Required, k) {
447447+ issues = append(issues, LintIssue{
448448+ FilePath: p,
449449+ NSID: nsid,
450450+ LintLevel: "warn",
451451+ LintName: "nullable-and-optional",
452452+ LintDescription: "object properties should not be both optional and nullable",
453453+ Message: fmt.Sprintf("field is both nullabor and optional: %s", k),
454454+ })
455455+ }
456456+ }
457457+ case lexicon.SchemaBlob:
458458+ // pass
459459+ case lexicon.SchemaParams:
460460+ // NOTE: CheckSchema already verifies that required are valid against property keys
461461+ for _, propdef := range v.Properties {
462462+ reciss := lintSchemaRecursive(p, nsid, propdef)
463463+ if len(reciss) > 0 {
464464+ issues = append(issues, reciss...)
465465+ }
466466+ // TODO: property name syntax
467467+ }
468468+ case lexicon.SchemaToken:
469469+ // pass
470470+ case lexicon.SchemaRef:
471471+ // TODO: resolve? locally vs globally?
472472+ case lexicon.SchemaUnion:
473473+ // TODO: open vs closed?
474474+ // TODO: check that refs actually resolve?
475475+ case lexicon.SchemaUnknown:
476476+ // pass
477477+ case lexicon.SchemaBody:
478478+ if v.Schema != nil {
479479+ // NOTE: CheckSchema already verified that v.Schema is an object, ref, or union
480480+ reciss := lintSchemaRecursive(p, nsid, *v.Schema)
481481+ if len(reciss) > 0 {
482482+ issues = append(issues, reciss...)
483483+ }
484484+ }
485485+ // TODO: empty/invalid Encoding (mimetype)
486486+ default:
487487+ slog.Info("no lint rules for recursive schema type", "type", fmt.Sprintf("%T", def.Inner), "nsid", nsid)
488488+ }
489489+490490+ return issues
491491+}