Skip to content
Open
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
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
36 changes: 36 additions & 0 deletions codemeta.go
Original file line number Diff line number Diff line change
@@ -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"`
}
137 changes: 137 additions & 0 deletions detect/codemeta.go
Original file line number Diff line number Diff line change
@@ -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"
}
}
91 changes: 91 additions & 0 deletions detect/codemeta_test.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions detect/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
7 changes: 2 additions & 5 deletions report/citation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions report/citation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading