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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,10 @@ Matching is case-insensitive and checks the repo root first, then `docs/`, `.git

Issue and pull request templates are reported under `resources.templates` so that contributors and coding agents can find and follow them. Unlike the single-path resources above this is a list: every template file found is included, split into `issue` and `pull_request` arrays plus a `config` path for the issue chooser. Detection covers the locations recognised by GitHub, GitLab, Gitea, and Forgejo, which means the repo root, `docs/`, `.github/`, `.gitea/`, `.forgejo/`, and `.gitlab/`, in both single-file form (`PULL_REQUEST_TEMPLATE.md`, `issue_template.md`) and directory form (`ISSUE_TEMPLATE/`, `PULL_REQUEST_TEMPLATE/`, GitLab's `issue_templates/` and `merge_request_templates/`). Merge request templates are reported under `pull_request` rather than getting their own field.

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.

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

Separately from resources, brief reports agent skills the project provides. These are packaged instructions an AI coding agent can load on demand, not guidance on how to work on this codebase. Detection currently covers Anthropic's `SKILL.md` convention: a `SKILL.md` file with YAML frontmatter under `skills/<name>/` or `.claude/skills/<name>/`. Each skill is listed with its name and description from the frontmatter (falling back to the directory name) and the path to its `SKILL.md`. In JSON they appear under `skills` with a `format` field set to `claude` so other skill formats can be added later without changing the shape.
Expand Down
3 changes: 2 additions & 1 deletion brief.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ type ResourceInfo struct {
Agents map[string]string `json:"agents,omitempty"`

Templates *TemplateInfo `json:"templates,omitempty"`
Citation *CitationInfo `json:"citation,omitempty"`
}

// TemplateInfo lists issue and pull request templates the project provides so
Expand Down Expand Up @@ -182,7 +183,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()
len(r.Agents) == 0 && r.Templates.Empty() && r.Citation == nil
}

// Skill is an agent skill the project provides: packaged instructions an AI
Expand Down
72 changes: 72 additions & 0 deletions citation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package brief

// CitationInfo contains declared metadata and independent parsing and validation outcomes.
type CitationInfo struct {
Path string `json:"path"`
ParseStatus string `json:"parse_status"`
ValidationStatus string `json:"validation_status,omitempty"`
CFFVersion string `json:"cff_version,omitempty"`
Title string `json:"title,omitempty"`
Type string `json:"type,omitempty"`
Version string `json:"version,omitempty"`
DateReleased string `json:"date_released,omitempty"`
DOI string `json:"doi,omitempty"`
URL string `json:"url,omitempty"`
Repository string `json:"repository,omitempty"`
RepositoryCode string `json:"repository_code,omitempty"`
RepositoryArtifact string `json:"repository_artifact,omitempty"`
Authors []CitationAuthor `json:"authors,omitempty"`
Identifiers []CitationIdentifier `json:"identifiers,omitempty"`
Licenses []string `json:"licenses,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Abstract string `json:"abstract,omitempty"`
Message string `json:"message,omitempty"`
PreferredCitation *CitationReference `json:"preferred_citation,omitempty"`
Diagnostics []CitationDiagnostic `json:"diagnostics,omitempty"`
}

// CitationAuthor preserves person and organisation name fields as declared.
type CitationAuthor struct {
Name string `json:"name,omitempty"`
GivenNames string `json:"given_names,omitempty"`
FamilyNames string `json:"family_names,omitempty"`
NameParticle string `json:"name_particle,omitempty"`
NameSuffix string `json:"name_suffix,omitempty"`
Affiliation string `json:"affiliation,omitempty"`
ORCID string `json:"orcid,omitempty"`
}

// CitationIdentifier is an identifier declared in the CFF identifiers list.
type CitationIdentifier struct {
Type string `json:"type,omitempty"`
Value string `json:"value,omitempty"`
Description string `json:"description,omitempty"`
}

// CitationReference describes the work the project asks users to cite.
type CitationReference struct {
Title string `json:"title,omitempty"`
Type string `json:"type,omitempty"`
Authors []CitationAuthor `json:"authors,omitempty"`
DOI string `json:"doi,omitempty"`
URL string `json:"url,omitempty"`
Identifiers []CitationIdentifier `json:"identifiers,omitempty"`
DatePublished string `json:"date_published,omitempty"`
Year string `json:"year,omitempty"`
Journal string `json:"journal,omitempty"`
Volume string `json:"volume,omitempty"`
Issue string `json:"issue,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Pages string `json:"pages,omitempty"`
Publisher *CitationAuthor `json:"publisher,omitempty"`
}

// CitationDiagnostic identifies a read, parse, or validation problem in the source file.
type CitationDiagnostic struct {
Code string `json:"code"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
}
110 changes: 110 additions & 0 deletions cmd/brief/citation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package main

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/git-pkgs/brief"
)

func TestCitationCLIHelper(_ *testing.T) {
root := os.Getenv("BRIEF_CITATION_ROOT")
if root == "" {
return
}
mode := os.Getenv("BRIEF_CITATION_MODE")
switch mode {
case "schema":
cmdSchema()
case "diff":
cmdDiff([]string{"--json", "HEAD"})
default:
cmdScan(append(strings.Fields(mode), root))
}
os.Exit(0)
}

func TestCitationCLI(t *testing.T) {
root, err := filepath.Abs("../../testdata/citation-project")
if err != nil {
t.Fatal(err)
}
for _, mode := range []string{"--json", "--human", "--markdown", "--human --verbose", "--markdown --verbose"} {
t.Run(mode, func(t *testing.T) {
out := citationCLI(t, root, mode)
if mode == "--json" {
var r brief.Report
if err := json.Unmarshal(out, &r); err != nil {
t.Fatal(err)
}
if r.Resources == nil || r.Resources.Citation == nil || r.Resources.Citation.ValidationStatus != "valid" || r.Resources.Citation.PreferredCitation.DOI != "10.1234/paper" {
t.Fatalf("citation output: %s", out)
}
return
}
for _, want := range []string{"Example Research Software", "Alex van Example III", "Research Team", "10.1234/software", "10.1234/archive", "10.1234/paper", "The accompanying paper", "1.20", "2024-02-29"} {
if !strings.Contains(string(out), want) {
t.Errorf("missing %q in output: %s", want, out)
}
}
if strings.Contains(mode, "verbose") && !strings.Contains(string(out), "Simulates example systems for research.") {
t.Fatalf("missing verbose metadata: %s", out)
}
})
}
}

func TestCitationCLISchema(t *testing.T) {
root := t.TempDir()
var schema struct {
Defs map[string]struct {
Properties map[string]json.RawMessage `json:"properties"`
} `json:"$defs"`
}
if err := json.Unmarshal(citationCLI(t, root, "schema"), &schema); err != nil {
t.Fatal(err)
}
for name, fields := range map[string][]string{
"resourceinfo": {"citation"}, "citationinfo": {"parse_status", "preferred_citation", "diagnostics"},
"citationdiagnostic": {"code", "line", "column"},
} {
for _, field := range fields {
if _, ok := schema.Defs[name].Properties[field]; !ok {
t.Errorf("schema missing %s.%s", name, field)
}
}
}
}

func TestCitationCLIDiff(t *testing.T) {
root := t.TempDir()
const content = "cff-version: 1.2.0\ntitle: First\nmessage: Cite\nauthors: [{name: Team}]\n"
writeScanFixture(t, root, "CITATION.cff", content)
runGitFixture(t, root, "init", "-q")
runGitFixture(t, root, "add", "CITATION.cff")
runGitFixture(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "initial")
writeScanFixture(t, root, "CITATION.cff", strings.Replace(content, "First", "Second", 1))
var r brief.Report
if err := json.Unmarshal(citationCLI(t, root, "diff"), &r); err != nil {
t.Fatal(err)
}
if r.Resources == nil || r.Resources.Citation == nil || r.Resources.Citation.Title != "Second" {
t.Fatalf("changed citation missing: %+v", r.Resources)
}
}

func citationCLI(t *testing.T, root, mode string) []byte {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=^TestCitationCLIHelper$")
cmd.Dir = root
cmd.Env = append(os.Environ(), "BRIEF_CITATION_ROOT="+root, "BRIEF_CITATION_MODE="+mode)
out, err := cmd.Output()
if err != nil {
t.Fatalf("citation CLI %s: %v\n%s", mode, err, out)
}
return out
}
31 changes: 31 additions & 0 deletions detect/bench_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
package detect

import (
"os"
"path/filepath"
"testing"

"github.com/git-pkgs/brief"
"github.com/git-pkgs/brief/kb"
)

func BenchmarkCitationProject(b *testing.B) {
b.Setenv("PATH", "")
knowledgeBase := loadKBForBench(b)
data, err := os.ReadFile("../testdata/citation-project/CITATION.cff")
if err != nil {
b.Fatal(err)
}
for _, name := range []string{"absent", "present"} {
b.Run(name, func(b *testing.B) {
root := b.TempDir()
if name == "present" {
if err := os.WriteFile(filepath.Join(root, "CITATION.cff"), data, 0o600); err != nil {
b.Fatal(err)
}
}
b.ReportAllocs()
for b.Loop() {
r, err := New(knowledgeBase, root).Run()
if err != nil {
b.Fatal(err)
}
if name == "present" && (r.Resources == nil || r.Resources.Citation == nil || r.Resources.Citation.ValidationStatus != "valid") {
b.Fatal("missing citation")
}
}
})
}
}

func loadKBForBench(b *testing.B) *kb.KnowledgeBase {
b.Helper()
knowledgeBase, err := kb.Load(brief.KnowledgeFS)
Expand Down
Loading
Loading