Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion brief.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
193 changes: 193 additions & 0 deletions cmd/brief/codemeta_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion cmd/brief/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions codemeta.go
Original file line number Diff line number Diff line change
@@ -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"`
}
128 changes: 128 additions & 0 deletions detect/codemeta.go
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading