From c5cc6c6cd41e4115bc2b414a55349bfb9dd89b60 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 26 Sep 2026 17:26:09 +0100 Subject: [PATCH 1/2] Add CodeMeta metadata to project reports --- README.md | 2 + brief.go | 3 +- cmd/brief/schema_test.go | 2 +- codemeta.go | 36 +++++++ detect/codemeta.go | 137 ++++++++++++++++++++++++ detect/codemeta_test.go | 91 ++++++++++++++++ detect/detect.go | 3 + detect/filter.go | 3 + go.mod | 1 + go.sum | 2 + report/codemeta.go | 76 +++++++++++++ report/codemeta_test.go | 38 +++++++ report/markdown.go | 1 + report/report.go | 13 +++ testdata/codemeta-project/codemeta.json | 19 ++++ 15 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 codemeta.go create mode 100644 detect/codemeta.go create mode 100644 detect/codemeta_test.go create mode 100644 report/codemeta.go create mode 100644 report/codemeta_test.go create mode 100644 testdata/codemeta-project/codemeta.json 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/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..d9926bd --- /dev/null +++ b/codemeta.go @@ -0,0 +1,36 @@ +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"` + 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..5d2d05f --- /dev/null +++ b/detect/codemeta.go @@ -0,0 +1,137 @@ +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 = doc.Name() + info.Description = doc.Description() + info.Version = doc.SoftwareVersion().Text() + info.CodeRepository = codemetaStrings(doc.CodeRepository()) + info.Licenses = codemetaStrings(doc.License()) + info.Keywords = codemetaStrings(doc.Keywords()) + info.ProgrammingLanguages = codemetaStrings(doc.ProgrammingLanguages()) + 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 codemetaStrings(value codemeta.Value) []string { + var values []string + for _, item := range value.Values() { + text := "" + if item.Kind() == codemeta.String { + text = item.Text() + } else if item.Kind() == codemeta.Object { + text = item.Get("@id").Text() + if text == "" { + text = item.Get("name").Text() + } + } + if text != "" { + values = append(values, text) + } + } + return values +} + +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 { + for _, nested := range agent.Agents() { + if author.Name == "" { + author.Name = codemetaAuthorName(nested) + } + } + } + authors = append(authors, author) + } + return authors +} + +func codemetaAuthor(agent codemeta.Agent) brief.CodemetaAuthor { + return brief.CodemetaAuthor{ + Name: codemetaAuthorName(agent), + GivenName: agent.GivenName(), + FamilyName: agent.FamilyName(), + Role: agent.RoleName().Text(), + Kind: codemetaAgentKind(agent.Kind()), + } +} + +func codemetaAuthorName(agent codemeta.Agent) string { + if name := agent.Name(); name != "" { + return name + } + return strings.TrimSpace(agent.GivenName() + " " + agent.FamilyName()) +} + +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..be6ec8f --- /dev/null +++ b/detect/codemeta_test.go @@ -0,0 +1,91 @@ +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 != "role" { + 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 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..b064f6f 100644 --- a/go.mod +++ b/go.mod @@ -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.1.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..966a221 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.1.0 h1:lgwbN9jKQZ9ngom6XfPN6n64Nq9QHPZ9ocG0nJu0R1w= +github.com/git-pkgs/codemeta v0.1.0/go.mod h1:AzSAEtGSnc0pnTC4efaP/6jo84k4ppOHWciAMS53oqA= 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/codemeta.go b/report/codemeta.go new file mode 100644 index 0000000..08bb2af --- /dev/null +++ b/report/codemeta.go @@ -0,0 +1,76 @@ +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(author.Name+" "+author.Role)) + } + } + } + return rows +} + +func codemetaAuthorSummary(authors []brief.CodemetaAuthor) string { + var names []string + for _, author := range authors[:min(len(authors), citationAuthorLimit)] { + if author.Name != "" { + names = append(names, author.Name) + } + } + if len(authors) > citationAuthorLimit { + names = append(names, fmt.Sprintf("and %d more", len(authors)-citationAuthorLimit)) + } + return strings.Join(names, ", ") +} 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