diff --git a/README.md b/README.md index db256e4..7aecb36 100644 --- a/README.md +++ b/README.md @@ -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//` or `.claude/skills//`. 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. diff --git a/brief.go b/brief.go index f9b6d68..d3e336f 100644 --- a/brief.go +++ b/brief.go @@ -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 @@ -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 diff --git a/citation.go b/citation.go new file mode 100644 index 0000000..77de6ac --- /dev/null +++ b/citation.go @@ -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"` +} diff --git a/cmd/brief/citation_test.go b/cmd/brief/citation_test.go new file mode 100644 index 0000000..2660f81 --- /dev/null +++ b/cmd/brief/citation_test.go @@ -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 +} diff --git a/detect/bench_test.go b/detect/bench_test.go index c39af2f..b278eae 100644 --- a/detect/bench_test.go +++ b/detect/bench_test.go @@ -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) diff --git a/detect/citation.go b/detect/citation.go new file mode 100644 index 0000000..060078b --- /dev/null +++ b/detect/citation.go @@ -0,0 +1,150 @@ +package detect + +import ( + "errors" + + "github.com/git-pkgs/brief" + "github.com/git-pkgs/citation" +) + +const citationByteLimit = 1 << 20 + +func (e *Engine) detectCitation(path string) *brief.CitationInfo { + info := &brief.CitationInfo{Path: path} + data, err := e.safeReadFileLimit(path, citationByteLimit+1) + if err != nil { + info.ParseStatus = "read_error" + info.Diagnostics = []brief.CitationDiagnostic{{Code: "read_error", Message: err.Error()}} + return info + } + doc, err := citation.ParseWithOptions(data, citation.ParseOptions{MaxBytes: citationByteLimit}) + if err != nil { + info.ParseStatus = "syntax_error" + switch { + case errors.Is(err, citation.ErrLimit): + info.ParseStatus = "limit_exceeded" + case errors.Is(err, citation.ErrUnsupported): + info.ParseStatus = "unsupported_syntax" + case errors.Is(err, citation.ErrType): + info.ParseStatus = "type_error" + } + var problem *citation.Error + if errors.As(err, &problem) { + info.Diagnostics = []brief.CitationDiagnostic{citationDiagnostic(problem.Diagnostic)} + } + return info + } + info.ParseStatus = "parsed" + info.ValidationStatus = "valid" + for _, issue := range doc.Validate() { + info.ValidationStatus = "invalid" + if issue.Code == "unsupported_version" { + info.ValidationStatus = "unsupported_version" + } + info.Diagnostics = append(info.Diagnostics, citationDiagnostic(issue)) + } + populateCitation(info, doc) + return info +} + +func citationDiagnostic(issue citation.Diagnostic) brief.CitationDiagnostic { + return brief.CitationDiagnostic{Code: issue.Code, Path: issue.Path, Message: issue.Message, Line: issue.Line, Column: issue.Column} +} + +func populateCitation(info *brief.CitationInfo, doc *citation.Document) { + info.CFFVersion = citationString(doc.Get("cff-version")) + info.Title = citationString(doc.Get("title")) + info.Type = citationString(doc.Get("type")) + info.Version = citationScalar(doc.Get("version")) + info.DateReleased = citationString(doc.Get("date-released")) + info.DOI = citationString(doc.Get("doi")) + info.URL = citationString(doc.Get("url")) + info.Repository = citationString(doc.Get("repository")) + info.RepositoryCode = citationString(doc.Get("repository-code")) + info.RepositoryArtifact = citationString(doc.Get("repository-artifact")) + info.Authors = citationAuthors(doc.Get("authors")) + info.Identifiers = citationIdentifiers(doc.Get("identifiers")) + info.Licenses = citationStrings(doc.Get("license")) + if license := citationString(doc.Get("license")); license != "" { + info.Licenses = []string{license} + } + info.Keywords = citationStrings(doc.Get("keywords")) + info.Abstract = citationString(doc.Get("abstract")) + info.Message = citationString(doc.Get("message")) + if value := doc.Get("preferred-citation"); value.Kind() == citation.Mapping { + info.PreferredCitation = citationReference(value) + } +} + +func citationString(v citation.Value) string { + if v.Kind() == citation.String { + return v.Text() + } + return "" +} + +func citationScalar(v citation.Value) string { + if v.Kind() == citation.Number { + return v.Text() + } + return citationString(v) +} + +func citationStrings(v citation.Value) []string { + var values []string + for _, item := range v.Items() { + if item.Kind() == citation.String { + values = append(values, item.Text()) + } + } + return values +} + +func citationAuthor(v citation.Value) brief.CitationAuthor { + return brief.CitationAuthor{ + Name: citationString(v.Get("name")), GivenNames: citationString(v.Get("given-names")), + FamilyNames: citationString(v.Get("family-names")), NameParticle: citationString(v.Get("name-particle")), + NameSuffix: citationString(v.Get("name-suffix")), Affiliation: citationString(v.Get("affiliation")), + ORCID: citationString(v.Get("orcid")), + } +} + +func citationAuthors(v citation.Value) []brief.CitationAuthor { + var authors []brief.CitationAuthor + for _, item := range v.Items() { + if item.Kind() == citation.Mapping { + authors = append(authors, citationAuthor(item)) + } + } + return authors +} + +func citationIdentifiers(v citation.Value) []brief.CitationIdentifier { + var identifiers []brief.CitationIdentifier + for _, item := range v.Items() { + if item.Kind() == citation.Mapping { + identifiers = append(identifiers, brief.CitationIdentifier{ + Type: citationString(item.Get("type")), Value: citationString(item.Get("value")), + Description: citationString(item.Get("description")), + }) + } + } + return identifiers +} + +func citationReference(v citation.Value) *brief.CitationReference { + ref := &brief.CitationReference{ + Title: citationString(v.Get("title")), Type: citationString(v.Get("type")), + Authors: citationAuthors(v.Get("authors")), DOI: citationString(v.Get("doi")), + URL: citationString(v.Get("url")), Identifiers: citationIdentifiers(v.Get("identifiers")), + DatePublished: citationString(v.Get("date-published")), Year: citationScalar(v.Get("year")), + Journal: citationString(v.Get("journal")), Volume: citationScalar(v.Get("volume")), + Issue: citationScalar(v.Get("issue")), Start: citationScalar(v.Get("start")), + End: citationScalar(v.Get("end")), Pages: citationScalar(v.Get("pages")), + } + if publisher := v.Get("publisher"); publisher.Kind() == citation.Mapping { + value := citationAuthor(publisher) + ref.Publisher = &value + } + return ref +} diff --git a/detect/citation_test.go b/detect/citation_test.go new file mode 100644 index 0000000..8fdf289 --- /dev/null +++ b/detect/citation_test.go @@ -0,0 +1,148 @@ +package detect + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/git-pkgs/brief" +) + +const minimalCitation = "cff-version: 1.2.0\nmessage: Cite\ntitle: Example\nauthors: [{name: Team}]\n" + +func TestCitationMetadata(t *testing.T) { + t.Setenv("PATH", "") + r, err := New(loadKB(t), "../testdata/citation-project").Run() + if err != nil { + t.Fatal(err) + } + info := r.Resources.Citation + if info == nil || info.ParseStatus != "parsed" || info.ValidationStatus != "valid" { + t.Fatalf("citation: %+v", info) + } + if info.Path != "CITATION.cff" || r.Resources.Metadata["citation"] != info.Path || info.CFFVersion != "1.2.0" { + t.Fatalf("provenance: %+v", info) + } + if info.Title != "Example Research Software" || info.Type != "software" || info.Version != "1.20" || info.DateReleased != "2024-02-29" { + t.Fatalf("release: %+v", info) + } + if len(info.Authors) != 2 || info.Authors[0].NameParticle != "van" || info.Authors[0].NameSuffix != "III" || info.Authors[0].Affiliation != "Example Institute" || info.Authors[0].ORCID == "" || info.Authors[1].Name != "Research Team" { + t.Fatalf("authors: %+v", info.Authors) + } + if !reflect.DeepEqual(info.Licenses, []string{"MIT", "Apache-2.0"}) || !reflect.DeepEqual(info.Keywords, []string{"simulation", "research"}) { + t.Fatalf("lists: %+v", info) + } + if info.Abstract != "Simulates example systems for research." || info.Message != "Please cite the accompanying paper." || info.URL != "https://example.org" || info.RepositoryCode == "" || info.RepositoryArtifact == "" { + t.Fatalf("text metadata: %+v", info) + } + if info.DOI != "10.1234/software" || len(info.Identifiers) != 1 || info.Identifiers[0].Value != "10.1234/archive" { + t.Fatalf("identifiers: %+v", info) + } + checkPreferredCitation(t, info.PreferredCitation) +} + +func checkPreferredCitation(t *testing.T, ref *brief.CitationReference) { + t.Helper() + if ref == nil || ref.DOI != "10.1234/paper" || ref.Title != "The accompanying paper" || ref.Type != "article" || ref.Year != "2024" || ref.Volume != "2" || ref.Issue != "1" || ref.Publisher == nil || ref.Publisher.Name != "Example Press" || ref.Start != "10" || ref.End != "20" { + t.Fatalf("preferred citation: %+v", ref) + } +} + +func TestCitationOutcomes(t *testing.T) { + t.Setenv("PATH", "") + for _, tc := range []struct { + name, content, parse, validation, code string + }{ + {"valid", minimalCitation, "parsed", "valid", ""}, + {"malformed", "authors: [", "syntax_error", "", "flow_end"}, + {"wrong root", "[a, b]", "type_error", "", "root_type"}, + {"invalid", "cff-version: 1.2.0\ntitle: Example\n", "parsed", "invalid", "required"}, + {"historical", strings.Replace(minimalCitation, "1.2.0", "1.0.3", 1), "parsed", "unsupported_version", "unsupported_version"}, + {"missing version", "title: Example\n", "parsed", "invalid", "required"}, + {"unsupported syntax", "title: !custom Example\n", "unsupported_syntax", "", "tag"}, + {"exact limit", minimalCitation + strings.Repeat("\n", citationByteLimit-len(minimalCitation)), "parsed", "valid", ""}, + {"over limit", minimalCitation + strings.Repeat("\n", citationByteLimit+1-len(minimalCitation)), "limit_exceeded", "", "byte_limit"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := scanCitation(t, "CITATION.cff", tc.content) + info := r.Resources.Citation + if info == nil || info.ParseStatus != tc.parse || info.ValidationStatus != tc.validation { + t.Fatalf("outcome: %+v", info) + } + if tc.code != "" && (len(info.Diagnostics) == 0 || info.Diagnostics[0].Code != tc.code) { + t.Fatalf("diagnostics: %+v", info.Diagnostics) + } + if r.Resources.Metadata["citation"] != "CITATION.cff" { + t.Fatal("source path lost") + } + }) + } +} + +func TestCitationDiscoveryAndProjection(t *testing.T) { + t.Setenv("PATH", "") + for _, name := range []string{"citation.CFF", "CITATION", "CITATION.md", "CITATION.bib", "CITATION.txt"} { + r := scanCitation(t, name, minimalCitation) + wantCFF := strings.EqualFold(filepath.Ext(name), ".cff") + if (r.Resources.Citation != nil) != wantCFF || r.Resources.Metadata["citation"] != name { + t.Fatalf("%s: %+v", name, r.Resources) + } + } + r := scanCitation(t, "CITATION.cff", "cff-version: 1.2.0\ntitle: true\nversion: false\nlicense: MIT\nkeywords: [fine, 12]\nauthors: [false, {name: 12}]\n") + info := r.Resources.Citation + if info.Title != "" || info.Version != "" || len(info.Authors) != 1 || info.Authors[0].Name != "" || !reflect.DeepEqual(info.Keywords, []string{"fine"}) || !reflect.DeepEqual(info.Licenses, []string{"MIT"}) || info.ValidationStatus != "invalid" { + t.Fatalf("invalid values converted to metadata: %+v", info) + } +} + +func TestCitationDiffFilter(t *testing.T) { + t.Setenv("PATH", "") + r := scanCitation(t, "CITATION.cff", minimalCitation) + kb := loadKB(t) + changed := FilterByChangedFiles(r, kb, []string{"CITATION.cff"}) + if changed.Resources == nil || changed.Resources.Citation == nil || changed.Resources.Citation.Title != "Example" { + t.Fatal("changed citation omitted") + } + unrelated := FilterByChangedFiles(r, kb, []string{"main.go"}) + if unrelated.Resources != nil && unrelated.Resources.Citation != nil { + t.Fatal("unrelated change retained citation") + } +} + +func TestCitationSymlinkEscape(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + target := filepath.Join(t.TempDir(), "private.cff") + if err := os.WriteFile(target, []byte(minimalCitation), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "CITATION.cff")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + r, err := New(loadKB(t), dir).Run() + if err != nil { + t.Fatal(err) + } + if r.Resources != nil && r.Resources.Citation != nil { + info := r.Resources.Citation + if info.ParseStatus != "read_error" || info.Title != "" { + t.Fatalf("escaped root: %+v", info) + } + } +} + +func scanCitation(t *testing.T, name, content string) *brief.Report { + t.Helper() + dir := t.TempDir() + writeFile(t, dir, name, content) + 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 ae76e37..8392688 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -1913,6 +1913,9 @@ func (e *Engine) detectResources() *brief.ResourceInfo { } res.Templates = e.detectTemplates() + if rel := res.Metadata["citation"]; strings.EqualFold(filepath.Ext(rel), ".cff") { + res.Citation = e.detectCitation(rel) + } if res.Empty() { return nil diff --git a/detect/filter.go b/detect/filter.go index ce1b38f..1e62ca9 100644 --- a/detect/filter.go +++ b/detect/filter.go @@ -211,6 +211,9 @@ func (fc *filterContext) filterResources(res *brief.ResourceInfo, changedFiles [ filterGroup("security", res.Security) filterGroup("metadata", res.Metadata) filterGroup("agents", res.Agents) + if res.Citation != nil && hit(res.Citation.Path) { + out.Citation = res.Citation + } if t := res.Templates; t != nil { ft := &brief.TemplateInfo{} diff --git a/detect/special_file_unix_test.go b/detect/special_file_unix_test.go index 947c6d7..d58ddc6 100644 --- a/detect/special_file_unix_test.go +++ b/detect/special_file_unix_test.go @@ -26,3 +26,18 @@ func TestProjectFileIndexExcludesNamedPipes(t *testing.T) { t.Fatal("named pipe was treated as a regular file") } } + +func TestCitationNamedPipe(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + if err := syscall.Mkfifo(filepath.Join(dir, "CITATION.cff"), 0o600); err != nil { + t.Fatal(err) + } + r, err := New(loadKB(t), dir).Run() + if err != nil { + t.Fatal(err) + } + if r.Resources != nil && r.Resources.Citation != nil && r.Resources.Citation.ParseStatus != "read_error" { + t.Fatalf("named pipe treated as citation data: %+v", r.Resources.Citation) + } +} diff --git a/go.mod b/go.mod index 327a8ff..64b7d3b 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.26.7 require ( github.com/BurntSushi/toml v1.6.0 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/enrichment v0.7.1 github.com/git-pkgs/forge v0.10.0 diff --git a/go.sum b/go.sum index cf21134..2a6b4d5 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/ecosyste-ms/ecosystems-go v0.4.0 h1:5A+zF+XWT8sYYYjlc2/tI1SmiDGzbHLyT github.com/ecosyste-ms/ecosystems-go v0.4.0/go.mod h1:FVswCrp3DQkur1HjVqfDF/gYrDSEmiFflntcB1G0DbA= github.com/git-pkgs/archives v0.7.1 h1:xLGYZkezsukkSXwJN1tAC0oxSe3szTw8PPtILDSHmG8= github.com/git-pkgs/archives v0.7.1/go.mod h1:vnRwCQ4eZ87tFzMiQ3GVIrOW6igJyog+2ad5YYCC3xQ= +github.com/git-pkgs/citation v0.1.0 h1:0toQBjxVOtaUsxRtcDJL9uZ6SYFb6IgbHgKpTpP0cv0= +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/enrichment v0.7.1 h1:8PRYE7gaB8y4M5wnRw/ymNDk0uOHtbQ8CEE7hF09Bv0= diff --git a/report/citation.go b/report/citation.go new file mode 100644 index 0000000..3577ada --- /dev/null +++ b/report/citation.go @@ -0,0 +1,151 @@ +package report + +import ( + "fmt" + "io" + "strings" + "unicode" + + "github.com/git-pkgs/brief" +) + +const citationAuthorLimit = 3 + +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) + } +} + +func mdCitation(w io.Writer, info *brief.CitationInfo, verbose bool) { + rows := citationRows(info, verbose) + if len(rows) == 0 { + return + } + _, _ = fmt.Fprint(w, "\n**Citation:**\n\n") + for _, row := range rows { + _, _ = fmt.Fprintf(w, "- %s: %s\n", row.label, escapeCitationMarkdown(row.value)) + } +} + +func citationRows(info *brief.CitationInfo, verbose bool) []citationRow { + if info == nil { + return nil + } + var rows []citationRow + add := func(label, value string) { + if value != "" { + rows = append(rows, citationRow{label, citationText(value)}) + } + } + title := info.Title + if title == "" { + title = info.Path + } + add("Citation", title) + add("Authors", citationAuthorSummary(info.Authors)) + add("Release", strings.TrimSpace(info.Version+" "+info.DateReleased)) + add("DOI", info.DOI) + for _, id := range info.Identifiers[:min(len(info.Identifiers), maxDisplayItems)] { + add("Identifier", strings.TrimSpace(id.Type+" "+id.Value+" "+id.Description)) + } + if ref := info.PreferredCitation; ref != nil { + add("Cite", ref.Title) + add("Preferred DOI", ref.DOI) + if verbose { + add("Cite authors", citationAuthorSummary(ref.Authors)) + add("Publication", strings.TrimSpace(ref.Journal+" "+ref.Year+" "+ref.DatePublished)) + add("Cite URL", ref.URL) + } + } + if info.ParseStatus == "parsed" { + add("CFF", strings.TrimSpace(info.CFFVersion+" "+info.ValidationStatus)) + } else { + 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) + } + add("CFF issue", strings.TrimSpace(location+" "+issue.Code+": "+issue.Message)) + } + if verbose { + rows = append(rows, citationDetailRows(info)...) + } + return rows +} + +func citationDetailRows(info *brief.CitationInfo) []citationRow { + rows := []citationRow{ + {"Type", info.Type}, {"URL", info.URL}, {"Repository", info.Repository}, + {"Source code", info.RepositoryCode}, {"Artifact", info.RepositoryArtifact}, + {"Licenses", strings.Join(info.Licenses, ", ")}, {"Keywords", strings.Join(info.Keywords, ", ")}, + {"Abstract", info.Abstract}, {"Instructions", info.Message}, + } + for _, author := range info.Authors[:min(len(info.Authors), maxDisplayItems)] { + if author.Affiliation != "" || author.ORCID != "" { + rows = append(rows, citationRow{"Author details", strings.TrimSpace(citationAuthorName(author) + " " + author.Affiliation + " " + author.ORCID)}) + } + } + var out []citationRow + for _, row := range rows { + if row.value != "" { + out = append(out, citationRow{row.label, citationText(row.value)}) + } + } + return out +} + +func citationAuthorSummary(authors []brief.CitationAuthor) string { + var names []string + for _, author := range authors[:min(len(authors), citationAuthorLimit)] { + if name := citationAuthorName(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 citationAuthorName(author brief.CitationAuthor) string { + if author.Name != "" { + return author.Name + } + var parts []string + for _, part := range []string{author.GivenNames, author.NameParticle, author.FamilyNames, author.NameSuffix} { + if part != "" { + parts = append(parts, part) + } + } + return strings.Join(parts, " ") +} + +func citationText(value string) string { + const maxRunes = 300 + value = strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return ' ' + } + if unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, sanitizeLine(value)) + runes := []rune(value) + if len(runes) > maxRunes { + return string(runes[:maxRunes]) + "..." + } + return value +} + +func escapeCitationMarkdown(value string) string { + return strings.NewReplacer( + "\\", "\\\\", "`", "\\`", "*", "\\*", "_", "\\_", "[", "\\[", "]", "\\]", + "<", "<", ">", ">", "&", "&", "|", "\\|", + ).Replace(value) +} diff --git a/report/citation_test.go b/report/citation_test.go new file mode 100644 index 0000000..349e823 --- /dev/null +++ b/report/citation_test.go @@ -0,0 +1,41 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/git-pkgs/brief" +) + +func TestCitationOutputEscapesAndBounds(t *testing.T) { + r := &brief.Report{Resources: &brief.ResourceInfo{Citation: &brief.CitationInfo{ + Path: "CITATION.cff", ParseStatus: "parsed", ValidationStatus: "invalid", + Title: "