diff --git a/README.md b/README.md index 178b7d3..9c8802e 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,8 @@ Issue and pull request templates are reported under `resources.templates` so tha When the citation file is a `CITATION.cff`, `resources.citation` also contains its title, authors, release, identifiers, and preferred citation. The existing `resources.metadata.citation` path is preserved. Human and Markdown output include a short citation summary; `--verbose` adds affiliations, repository URLs, licenses, keywords, and the abstract. Licenses declared in CFF are reported separately from the detected repository license. +When a project contains `codemeta.json`, `resources.codemeta` contains its name, description, software version, repository, licenses, keywords, programming languages, authors, CodeMeta context version, and validation diagnostics. The existing `resources.metadata.codemeta` path is preserved. Human and Markdown output show a short summary; `--verbose` adds the remaining extracted fields. + Parsing and validation have separate statuses, so older CFF versions and invalid metadata can still provide useful fields. Diagnostics include field paths and source positions where available. Unreadable files, malformed YAML, and files over the 1 MiB limit produce citation diagnostics without failing the scan. JSON retains the projected metadata and all diagnostics; human and Markdown summaries limit long values and lists. ## Agent skills diff --git a/brief.go b/brief.go index d3e336f..213f88f 100644 --- a/brief.go +++ b/brief.go @@ -128,6 +128,7 @@ type ResourceInfo struct { Templates *TemplateInfo `json:"templates,omitempty"` Citation *CitationInfo `json:"citation,omitempty"` + Codemeta *CodemetaInfo `json:"codemeta,omitempty"` } // TemplateInfo lists issue and pull request templates the project provides so @@ -183,7 +184,7 @@ func (r *ResourceInfo) Empty() bool { r.License == "" && len(r.Legal) == 0 && len(r.Community) == 0 && len(r.Security) == 0 && len(r.Metadata) == 0 && - len(r.Agents) == 0 && r.Templates.Empty() && r.Citation == nil + len(r.Agents) == 0 && r.Templates.Empty() && r.Citation == nil && r.Codemeta == nil } // Skill is an agent skill the project provides: packaged instructions an AI diff --git a/cmd/brief/codemeta_test.go b/cmd/brief/codemeta_test.go new file mode 100644 index 0000000..118df11 --- /dev/null +++ b/cmd/brief/codemeta_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/git-pkgs/brief" +) + +func TestCodemetaCLIProjection(t *testing.T) { + root := t.TempDir() + const content = `{ + "@context":"https://w3id.org/codemeta/3.0", + "name":"Example", + "codeRepository":{"id":"https://example.org/repo"}, + "license":{"id":"https://spdx.org/licenses/MIT"}, + "programmingLanguage":{"schema:name":"Go"}, + "keywords":{"@set":["science","metadata"]}, + "author":{ + "@type":"Role","name":"Development","roleName":"developer", + "author":[ + {"@type":"Person","givenName":"Ada","familyName":"Lovelace"}, + {"@type":"Person","givenName":"Grace","familyName":"Hopper"} + ] + } + }` + if err := os.WriteFile(filepath.Join(root, "codemeta.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + var r brief.Report + if err := json.Unmarshal(citationCLI(t, root, "--json"), &r); err != nil { + t.Fatal(err) + } + if r.Resources == nil || r.Resources.Codemeta == nil { + t.Fatal("missing CodeMeta metadata") + } + info := r.Resources.Codemeta + if info.ValidationStatus != "valid" { + t.Fatalf("invalid metadata: %+v", info) + } + for field, pair := range map[string][2][]string{ + "repository": {info.CodeRepository, {"https://example.org/repo"}}, + "licenses": {info.Licenses, {"https://spdx.org/licenses/MIT"}}, + "languages": {info.ProgrammingLanguages, {"Go"}}, + "keywords": {info.Keywords, {"science", "metadata"}}, + } { + if !reflect.DeepEqual(pair[0], pair[1]) { + t.Errorf("%s = %v, want %v", field, pair[0], pair[1]) + } + } + wantAuthors := []brief.CodemetaAuthor{ + {Name: "Ada Lovelace", GivenName: "Ada", FamilyName: "Lovelace", Role: "developer", Kind: "person"}, + {Name: "Grace Hopper", GivenName: "Grace", FamilyName: "Hopper", Role: "developer", Kind: "person"}, + } + if !reflect.DeepEqual(info.Authors, wantAuthors) { + t.Errorf("authors = %+v, want %+v", info.Authors, wantAuthors) + } + for _, mode := range []string{"--human --verbose", "--markdown --verbose"} { + t.Run(mode, func(t *testing.T) { + out := string(citationCLI(t, root, mode)) + for _, want := range []string{"Ada Lovelace", "Grace Hopper", "developer", "https://example.org/repo", "https://spdx.org/licenses/MIT", "Go", "science, metadata"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in output: %s", want, out) + } + } + }) + } +} + +func TestCodemetaCLIWrappedFields(t *testing.T) { + root := t.TempDir() + const content = `{ + "@context":"https://w3id.org/codemeta/3.0", + "name":{"@value":"Example"}, + "description":{"@value":"Description","@language":null}, + "version":{"@set":["1.2","1.3"]}, + "author":[{"@set":[ + {"@type":"Person","givenName":{"@list":["Ada","Augusta"]},"familyName":{"@value":"Lovelace"}}, + {"@context":[{}],"schema:name":{"@value":"Grace Hopper"}}, + {"@type":"Role","roleName":{"@set":["creator","developer"]},"author":[{"@set":[{"name":"Team"}]}]} + ]}] + }` + if err := os.WriteFile(filepath.Join(root, "codemeta.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + var r brief.Report + if err := json.Unmarshal(citationCLI(t, root, "--json"), &r); err != nil { + t.Fatal(err) + } + if r.Resources == nil || r.Resources.Codemeta == nil { + t.Fatal("missing CodeMeta") + } + info := r.Resources.Codemeta + if info.ValidationStatus != "valid" || info.Name != "Example" || info.Description != "Description" || info.Version != "1.2, 1.3" { + t.Fatalf("metadata: %+v", info) + } + want := []brief.CodemetaAuthor{ + {Name: "Ada Augusta Lovelace", GivenName: "Ada Augusta", FamilyName: "Lovelace", Kind: "person"}, + {Name: "Grace Hopper", Kind: "organization"}, + {Name: "Team", Role: "creator, developer", Kind: "organization"}, + } + if !reflect.DeepEqual(info.Authors, want) { + t.Fatalf("authors: %+v, want %+v", info.Authors, want) + } + for _, mode := range []string{"--human --verbose", "--markdown --verbose"} { + out := string(citationCLI(t, root, mode)) + for _, value := range []string{"Example", "Description", "1.2, 1.3", "Ada Augusta Lovelace", "Grace Hopper", "Team", "creator, developer"} { + if !strings.Contains(out, value) { + t.Errorf("%s: missing %q in %s", mode, value, out) + } + } + } +} + +func TestCodemetaCLIAuthorIdentifiers(t *testing.T) { + const identifier = "https://example.org/ada" + for _, tc := range []struct { + name, author, display string + want brief.CodemetaAuthor + }{ + {"reference", `{"@id":"` + identifier + `"}`, identifier, + brief.CodemetaAuthor{Identifier: identifier, Kind: "reference"}}, + {"alias", `{"id":"` + identifier + `"}`, identifier, + brief.CodemetaAuthor{Identifier: identifier, Kind: "reference"}}, + {"named person", `{"@type":"Person","@id":"` + identifier + `","name":"Ada"}`, "Ada", + brief.CodemetaAuthor{Name: "Ada", Identifier: identifier, Kind: "person"}}, + {"role reference", `{"@type":"Role","roleName":"developer","author":{"@id":"` + identifier + `"}}`, identifier, + brief.CodemetaAuthor{Identifier: identifier, Role: "developer", Kind: "reference"}}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + content := `{"@context":"https://w3id.org/codemeta/3.0","author":` + tc.author + `}` + if err := os.WriteFile(filepath.Join(root, "codemeta.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + var r brief.Report + if err := json.Unmarshal(citationCLI(t, root, "--json"), &r); err != nil { + t.Fatal(err) + } + if r.Resources == nil || r.Resources.Codemeta == nil { + t.Fatal("missing CodeMeta metadata") + } + info := r.Resources.Codemeta + if info.ValidationStatus != "valid" || !reflect.DeepEqual(info.Authors, []brief.CodemetaAuthor{tc.want}) { + t.Fatalf("metadata: %+v, want author %+v", info, tc.want) + } + for _, mode := range []string{"--human", "--markdown", "--human --verbose", "--markdown --verbose"} { + checkCodemetaAuthorOutput(t, root, mode, tc.display, tc.want) + } + }) + } +} + +func checkCodemetaAuthorOutput(t *testing.T, root, mode, display string, author brief.CodemetaAuthor) { + t.Helper() + out := string(citationCLI(t, root, mode)) + if !strings.Contains(strings.Join(strings.Fields(out), " "), "Authors: "+display) { + t.Errorf("%s: missing author %q in %s", mode, display, out) + } + if author.Name != "" && strings.Contains(out, author.Identifier) { + t.Errorf("%s: identifier displayed instead of name: %s", mode, out) + } + if author.Role != "" && strings.Contains(mode, "--verbose") && !strings.Contains(out, display+" "+author.Role) { + t.Errorf("%s: missing author role in %s", mode, out) + } +} + +func TestCodemetaCLIValidationRegressions(t *testing.T) { + for _, tc := range []struct{ name, fields, status string }{ + {"empty person field", `"author":{"@type":"Organization","name":"Team","givenName":null}`, "valid"}, + {"invalid reference", `"codeRepository":{"@id":"not an IRI"}`, "invalid"}, + {"null literal language", `"name":{"@value":"Example","@language":null}`, "valid"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + content := `{"@context":"https://w3id.org/codemeta/3.0",` + tc.fields + `}` + if err := os.WriteFile(filepath.Join(root, "codemeta.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + var r brief.Report + if err := json.Unmarshal(citationCLI(t, root, "--json"), &r); err != nil { + t.Fatal(err) + } + if r.Resources == nil || r.Resources.Codemeta == nil || r.Resources.Codemeta.ValidationStatus != tc.status { + t.Fatalf("metadata: %+v", r.Resources) + } + }) + } +} diff --git a/cmd/brief/schema_test.go b/cmd/brief/schema_test.go index 06aef42..c466000 100644 --- a/cmd/brief/schema_test.go +++ b/cmd/brief/schema_test.go @@ -46,7 +46,7 @@ func TestSchemaForType_GeneratesDefs(t *testing.T) { schemaForType(reflect.TypeFor[brief.Report](), defs) // Should have defs for nested struct types like Detection, Command, etc. - expectedDefs := []string{"detection", "command", "script", "stats"} + expectedDefs := []string{"detection", "command", "script", "stats", "codemetainfo", "codemetaauthor", "codemetadiagnostic"} for _, name := range expectedDefs { if _, ok := defs[name]; !ok { t.Errorf("expected $defs to contain %q", name) diff --git a/codemeta.go b/codemeta.go new file mode 100644 index 0000000..c95c3fe --- /dev/null +++ b/codemeta.go @@ -0,0 +1,37 @@ +package brief + +// CodemetaInfo contains declared software metadata and its parse and validation outcomes. +type CodemetaInfo struct { + Path string `json:"path"` + ParseStatus string `json:"parse_status"` + ValidationStatus string `json:"validation_status,omitempty"` + ContextVersion string `json:"context_version,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + CodeRepository []string `json:"code_repository,omitempty"` + Licenses []string `json:"licenses,omitempty"` + Keywords []string `json:"keywords,omitempty"` + ProgrammingLanguages []string `json:"programming_languages,omitempty"` + Authors []CodemetaAuthor `json:"authors,omitempty"` + Diagnostics []CodemetaDiagnostic `json:"diagnostics,omitempty"` +} + +// CodemetaAuthor is a person, organisation, or role declared as an author. +type CodemetaAuthor struct { + Name string `json:"name,omitempty"` + Identifier string `json:"identifier,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + Role string `json:"role,omitempty"` + Kind string `json:"kind,omitempty"` +} + +// CodemetaDiagnostic identifies a read, parse, or validation problem in codemeta.json. +type CodemetaDiagnostic struct { + Code string `json:"code"` + Path string `json:"path,omitempty"` + Message string `json:"message"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` +} diff --git a/detect/codemeta.go b/detect/codemeta.go new file mode 100644 index 0000000..11a4827 --- /dev/null +++ b/detect/codemeta.go @@ -0,0 +1,128 @@ +package detect + +import ( + "errors" + "strings" + + "github.com/git-pkgs/brief" + "github.com/git-pkgs/codemeta" +) + +const codemetaByteLimit = 1 << 20 + +func (e *Engine) detectCodemeta(path string) *brief.CodemetaInfo { + info := &brief.CodemetaInfo{Path: path} + data, err := e.safeReadFileLimit(path, codemetaByteLimit+1) + if err != nil { + info.ParseStatus = "read_error" + info.Diagnostics = []brief.CodemetaDiagnostic{{Code: "read_error", Message: err.Error()}} + return info + } + doc, err := codemeta.ParseWithOptions(data, codemeta.ParseOptions{MaxBytes: codemetaByteLimit}) + if err != nil { + info.ParseStatus = "syntax_error" + switch { + case errors.Is(err, codemeta.ErrLimit): + info.ParseStatus = "limit_exceeded" + case errors.Is(err, codemeta.ErrUnsupported): + info.ParseStatus = "unsupported_syntax" + case errors.Is(err, codemeta.ErrType): + info.ParseStatus = "type_error" + } + var problem *codemeta.Error + if errors.As(err, &problem) { + info.Diagnostics = []brief.CodemetaDiagnostic{codemetaDiagnostic(problem.Diagnostic)} + } + return info + } + info.ParseStatus = "parsed" + info.ValidationStatus = "valid" + info.ContextVersion = string(doc.Version()) + info.Name = strings.Join(doc.Strings("name"), ", ") + info.Description = strings.Join(doc.Strings("description"), ", ") + info.Version = strings.Join(doc.Strings("version"), ", ") + info.CodeRepository = doc.Strings("codeRepository") + info.Licenses = doc.Strings("license") + info.Keywords = doc.Strings("keywords") + info.ProgrammingLanguages = doc.Strings("programmingLanguage") + info.Authors = codemetaAuthors(doc.Author()) + for _, issue := range doc.Validate() { + if info.ValidationStatus != "unsupported_version" { + info.ValidationStatus = "invalid" + } + if issue.Code == "unsupported_version" { + info.ValidationStatus = "unsupported_version" + } + info.Diagnostics = append(info.Diagnostics, codemetaDiagnostic(issue)) + } + return info +} + +func codemetaDiagnostic(issue codemeta.Diagnostic) brief.CodemetaDiagnostic { + return brief.CodemetaDiagnostic{ + Code: issue.Code, Path: issue.Path, Message: issue.Message, + Line: issue.Line, Column: issue.Column, + } +} + +func codemetaAuthors(agents []codemeta.Agent) []brief.CodemetaAuthor { + authors := make([]brief.CodemetaAuthor, 0, len(agents)) + for _, agent := range agents { + author := codemetaAuthor(agent) + if agent.Kind() == codemeta.AgentRole { + nested := codemetaAuthors(agent.Agents()) + for _, member := range nested { + if member.Role == "" { + member.Role = author.Role + } + authors = append(authors, member) + } + if len(nested) != 0 { + continue + } + } + authors = append(authors, author) + } + return authors +} + +func codemetaAuthor(agent codemeta.Agent) brief.CodemetaAuthor { + return brief.CodemetaAuthor{ + Name: codemetaAuthorName(agent), + Identifier: agent.Identifier().Text(), + GivenName: strings.Join(agent.Strings("givenName"), " "), + FamilyName: strings.Join(agent.Strings("familyName"), " "), + Role: strings.Join(agent.Strings("roleName"), ", "), + Kind: codemetaAgentKind(agent.Kind()), + } +} + +func codemetaAuthorName(agent codemeta.Agent) string { + if agent.Kind() == codemeta.AgentText { + return agent.Name() + } + if name := strings.Join(agent.Strings("name"), ", "); name != "" { + return name + } + names := append(agent.Strings("givenName"), agent.Strings("familyName")...) + return strings.Join(names, " ") +} + +func codemetaAgentKind(kind codemeta.AgentKind) string { + switch kind { + case codemeta.AgentText: + return "text" + case codemeta.AgentReference: + return "reference" + case codemeta.AgentPerson: + return "person" + case codemeta.AgentOrganization: + return "organization" + case codemeta.AgentRole: + return "role" + case codemeta.AgentConflict: + return "conflict" + default: + return "unknown" + } +} diff --git a/detect/codemeta_test.go b/detect/codemeta_test.go new file mode 100644 index 0000000..78898af --- /dev/null +++ b/detect/codemeta_test.go @@ -0,0 +1,112 @@ +package detect + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/git-pkgs/brief" +) + +func TestCodemetaMetadata(t *testing.T) { + t.Setenv("PATH", "") + r, err := New(loadKB(t), "../testdata/codemeta-project").Run() + if err != nil { + t.Fatal(err) + } + info := r.Resources.Codemeta + if info == nil || info.ParseStatus != "parsed" || info.ValidationStatus != "valid" { + t.Fatalf("codemeta: %+v", info) + } + if info.Path != "codemeta.json" || r.Resources.Metadata["codemeta"] != info.Path || info.ContextVersion != "3.0" || info.Name != "Example Software" || info.Description != "Software described with CodeMeta." || info.Version != "1.2.0" { + t.Fatalf("metadata: %+v", info) + } + if !reflect.DeepEqual(info.CodeRepository, []string{"https://github.com/example/codemeta-project"}) || !reflect.DeepEqual(info.Licenses, []string{"https://spdx.org/licenses/MIT.html"}) || !reflect.DeepEqual(info.Keywords, []string{"metadata", "science"}) || !reflect.DeepEqual(info.ProgrammingLanguages, []string{"Go"}) { + t.Fatalf("lists: %+v", info) + } + if len(info.Authors) != 2 || info.Authors[0].Name != "Ada Lovelace" || info.Authors[0].Kind != "person" || info.Authors[1].Name != "Research Team" || info.Authors[1].Role != "creator" || info.Authors[1].Kind != "organization" { + t.Fatalf("authors: %+v", info.Authors) + } +} + +func TestCodemetaOutcomes(t *testing.T) { + for _, tc := range []struct{ name, content, parse, validation, code string }{ + {"malformed", `{"name":`, "syntax_error", "", "syntax"}, + {"wrong root", `[]`, "type_error", "", "root_type"}, + {"unsupported context", `{"@context":"https://w3id.org/codemeta/4.0","name":"Example"}`, "parsed", "unsupported_version", "unsupported_version"}, + {"invalid metadata", `{"@context":"https://w3id.org/codemeta/3.0","name":4}`, "parsed", "invalid", "value_type"}, + {"unsupported context with field error", `{"@context":"https://w3id.org/codemeta/4.0","@id":4}`, "parsed", "unsupported_version", "unsupported_version"}, + {"oversized", `{"name":"` + strings.Repeat("x", codemetaByteLimit) + `"}`, "limit_exceeded", "", "byte_limit"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := scanCodemeta(t, tc.content) + info := r.Resources.Codemeta + if info == nil || info.ParseStatus != tc.parse || info.ValidationStatus != tc.validation { + t.Fatalf("outcome: %+v", info) + } + if len(info.Diagnostics) == 0 || info.Diagnostics[0].Code != tc.code { + t.Fatalf("diagnostics: %+v", info.Diagnostics) + } + if r.Resources.Metadata["codemeta"] != "codemeta.json" { + t.Fatal("source path lost") + } + }) + } +} + +func TestCodemetaDiffFilter(t *testing.T) { + t.Setenv("PATH", "") + r, err := New(loadKB(t), "../testdata/codemeta-project").Run() + if err != nil { + t.Fatal(err) + } + kb := loadKB(t) + changed := FilterByChangedFiles(r, kb, []string{"codemeta.json"}) + if changed.Resources == nil || changed.Resources.Codemeta == nil || changed.Resources.Codemeta.Name != "Example Software" { + t.Fatal("changed codemeta omitted") + } + unrelated := FilterByChangedFiles(r, kb, []string{"main.go"}) + if unrelated.Resources != nil && unrelated.Resources.Codemeta != nil { + t.Fatal("unrelated change retained codemeta") + } +} + +func TestCodemetaNestedRoles(t *testing.T) { + r := scanCodemeta(t, `{ + "@context":"https://w3id.org/codemeta/3.0", + "author":[ + {"@type":"Role","roleName":"creator","author":[ + {"@type":"Role","roleName":"developer","author":{"name":"Ada"}}, + {"name":"Grace"} + ]}, + {"@type":"Role","roleName":"maintainer","name":"Research Team"} + ] + }`) + want := []brief.CodemetaAuthor{ + {Name: "Ada", Role: "developer", Kind: "organization"}, + {Name: "Grace", Role: "creator", Kind: "organization"}, + {Name: "Research Team", Role: "maintainer", Kind: "role"}, + } + if !reflect.DeepEqual(r.Resources.Codemeta.Authors, want) { + t.Fatalf("authors = %+v, want %+v", r.Resources.Codemeta.Authors, want) + } +} + +func scanCodemeta(t *testing.T, content string) *brief.Report { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "codemeta.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + r, err := New(loadKB(t), dir).Run() + if err != nil { + t.Fatal(err) + } + if r.Resources == nil { + t.Fatal("missing resources") + } + return r +} diff --git a/detect/detect.go b/detect/detect.go index 8392688..d0f2f7d 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -1916,6 +1916,9 @@ func (e *Engine) detectResources() *brief.ResourceInfo { if rel := res.Metadata["citation"]; strings.EqualFold(filepath.Ext(rel), ".cff") { res.Citation = e.detectCitation(rel) } + if rel := res.Metadata["codemeta"]; strings.EqualFold(filepath.Base(rel), "codemeta.json") { + res.Codemeta = e.detectCodemeta(rel) + } if res.Empty() { return nil diff --git a/detect/filter.go b/detect/filter.go index 1e62ca9..589b33a 100644 --- a/detect/filter.go +++ b/detect/filter.go @@ -214,6 +214,9 @@ func (fc *filterContext) filterResources(res *brief.ResourceInfo, changedFiles [ if res.Citation != nil && hit(res.Citation.Path) { out.Citation = res.Citation } + if res.Codemeta != nil && hit(res.Codemeta.Path) { + out.Codemeta = res.Codemeta + } if t := res.Templates; t != nil { ft := &brief.TemplateInfo{} diff --git a/go.mod b/go.mod index 64b7d3b..6e44830 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/git-pkgs/brief -go 1.26.0 +go 1.26.1 toolchain go1.26.7 @@ -9,6 +9,7 @@ require ( github.com/git-pkgs/archives v0.7.1 github.com/git-pkgs/citation v0.1.0 github.com/git-pkgs/clone v0.7.3 + github.com/git-pkgs/codemeta v0.3.0 github.com/git-pkgs/enrichment v0.7.1 github.com/git-pkgs/forge v0.10.0 github.com/git-pkgs/licensecheck v0.4.1 diff --git a/go.sum b/go.sum index 2a6b4d5..9c3d00b 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/git-pkgs/citation v0.1.0 h1:0toQBjxVOtaUsxRtcDJL9uZ6SYFb6IgbHgKpTpP0c github.com/git-pkgs/citation v0.1.0/go.mod h1:4vrS78/jjHeusS+eozjP/d3pX2NogEi1R/7gLVqkgqs= github.com/git-pkgs/clone v0.7.3 h1:QxlcCOAxRjPeLygc+gd74XSAE1BCVG6OQvKDczacwcQ= github.com/git-pkgs/clone v0.7.3/go.mod h1:AzlK7H7mgTm016cTyePUDCyqLP5KRXqKgOyTsm+KJ3s= +github.com/git-pkgs/codemeta v0.3.0 h1:zkWndmquCv2tPIHCHb+JXx3eKjmxRoD48f4/3sNAlGw= +github.com/git-pkgs/codemeta v0.3.0/go.mod h1:R1vGXVohTa22vVKaV3/8xVs3+tcgvqc4ziaMikdXc/I= github.com/git-pkgs/enrichment v0.7.1 h1:8PRYE7gaB8y4M5wnRw/ymNDk0uOHtbQ8CEE7hF09Bv0= github.com/git-pkgs/enrichment v0.7.1/go.mod h1:QYLG8MtVWPqZojnq7KBKK/lllBWSjLnqvJCWvzGuNwU= github.com/git-pkgs/forge v0.10.0 h1:ANVH4tErN1Io+Fv3h9vLvjg4Krj00K9ha+RfN53wDr4= diff --git a/report/citation.go b/report/citation.go index 3577ada..d0ab7b3 100644 --- a/report/citation.go +++ b/report/citation.go @@ -15,7 +15,7 @@ type citationRow struct{ label, value string } func printCitation(w io.Writer, info *brief.CitationInfo, verbose bool) { for _, row := range citationRows(info, verbose) { - _, _ = fmt.Fprintf(w, "%-14s %s\n", row.label+":", row.value) + _, _ = fmt.Fprintf(w, "%-12s %s\n", row.label+":", row.value) } } @@ -66,10 +66,7 @@ func citationRows(info *brief.CitationInfo, verbose bool) []citationRow { add("CFF", info.ParseStatus) } for _, issue := range info.Diagnostics[:min(len(info.Diagnostics), citationAuthorLimit)] { - location := issue.Path - if issue.Line != 0 { - location = fmt.Sprintf("%s:%d:%d %s", info.Path, issue.Line, issue.Column, issue.Path) - } + location := diagnosticLocation(info.Path, issue.Path, issue.Line, issue.Column) add("CFF issue", strings.TrimSpace(location+" "+issue.Code+": "+issue.Message)) } if verbose { diff --git a/report/citation_test.go b/report/citation_test.go index 349e823..2c833f2 100644 --- a/report/citation_test.go +++ b/report/citation_test.go @@ -8,6 +8,18 @@ import ( "github.com/git-pkgs/brief" ) +func TestCitationDiagnosticWithoutFieldPath(t *testing.T) { + r := &brief.Report{Resources: &brief.ResourceInfo{Citation: &brief.CitationInfo{ + Path: "CITATION.cff", ParseStatus: "unsupported_syntax", + Diagnostics: []brief.CitationDiagnostic{{Code: "tag", Message: "unsupported YAML tag", Line: 1, Column: 8}}, + }}} + var out bytes.Buffer + Human(&out, r, false) + if want := "CITATION.cff:1:8 tag: unsupported YAML tag"; !strings.Contains(out.String(), want) { + t.Errorf("human output missing %q: %s", want, out.String()) + } +} + func TestCitationOutputEscapesAndBounds(t *testing.T) { r := &brief.Report{Resources: &brief.ResourceInfo{Citation: &brief.CitationInfo{ Path: "CITATION.cff", ParseStatus: "parsed", ValidationStatus: "invalid", diff --git a/report/codemeta.go b/report/codemeta.go new file mode 100644 index 0000000..8ab4673 --- /dev/null +++ b/report/codemeta.go @@ -0,0 +1,83 @@ +package report + +import ( + "fmt" + "io" + "strings" + + "github.com/git-pkgs/brief" +) + +func printCodemeta(w io.Writer, info *brief.CodemetaInfo, verbose bool) { + for _, row := range codemetaRows(info, verbose) { + _, _ = fmt.Fprintf(w, "%-12s %s\n", row.label+":", citationText(row.value)) + } +} + +func mdCodemeta(w io.Writer, info *brief.CodemetaInfo, verbose bool) { + rows := codemetaRows(info, verbose) + if len(rows) == 0 { + return + } + _, _ = fmt.Fprint(w, "\n**CodeMeta:**\n\n") + for _, row := range rows { + _, _ = fmt.Fprintf(w, "- %s: %s\n", row.label, escapeCitationMarkdown(citationText(row.value))) + } +} + +func codemetaRows(info *brief.CodemetaInfo, verbose bool) []citationRow { + if info == nil { + return nil + } + var rows []citationRow + add := func(label, value string) { + if value != "" { + rows = append(rows, citationRow{label, value}) + } + } + add("Software", info.Name) + add("Authors", codemetaAuthorSummary(info.Authors)) + add("Version", info.Version) + status := info.ParseStatus + if info.ValidationStatus != "" { + status = strings.TrimSpace(info.ContextVersion + " " + info.ValidationStatus) + } + add("CodeMeta", status) + for _, issue := range info.Diagnostics[:min(len(info.Diagnostics), citationAuthorLimit)] { + location := diagnosticLocation(info.Path, issue.Path, issue.Line, issue.Column) + add("CodeMeta issue", strings.TrimSpace(location+" "+issue.Code+": "+issue.Message)) + } + if verbose { + add("Description", info.Description) + add("Code", strings.Join(info.CodeRepository, ", ")) + add("Licenses", strings.Join(info.Licenses, ", ")) + add("Keywords", strings.Join(info.Keywords, ", ")) + add("Languages", strings.Join(info.ProgrammingLanguages, ", ")) + for _, author := range info.Authors[:min(len(info.Authors), maxDisplayItems)] { + if author.Role != "" { + add("Author role", strings.TrimSpace(codemetaAuthorName(author)+" "+author.Role)) + } + } + } + return rows +} + +func codemetaAuthorSummary(authors []brief.CodemetaAuthor) string { + var names []string + for _, author := range authors[:min(len(authors), citationAuthorLimit)] { + if name := codemetaAuthorName(author); name != "" { + names = append(names, name) + } + } + if len(authors) > citationAuthorLimit { + names = append(names, fmt.Sprintf("and %d more", len(authors)-citationAuthorLimit)) + } + return strings.Join(names, ", ") +} + +func codemetaAuthorName(author brief.CodemetaAuthor) string { + if author.Name != "" { + return author.Name + } + return author.Identifier +} diff --git a/report/codemeta_test.go b/report/codemeta_test.go new file mode 100644 index 0000000..0847f07 --- /dev/null +++ b/report/codemeta_test.go @@ -0,0 +1,38 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/git-pkgs/brief" +) + +func TestCodemetaOutput(t *testing.T) { + r := &brief.Report{Resources: &brief.ResourceInfo{Codemeta: &brief.CodemetaInfo{ + Path: "codemeta.json", ParseStatus: "parsed", ValidationStatus: "invalid", ContextVersion: "3.0", + Name: "Example *Software*", Version: "1.2.0", Description: "A