Skip to content

Commit deea2f6

Browse files
authored
Merge pull request #50 from git-pkgs/expand-resources
Expand resource detection with grouped community files
2 parents 359cb95 + 0ea3273 commit deea2f6

12 files changed

Lines changed: 551 additions & 93 deletions

File tree

CONTRIBUTING.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,23 @@ The knowledge base is validated on every test run. Malformed TOML will fail the
156156
Files prefixed with `_` in ecosystem directories are shared config, not tool definitions:
157157

158158
- `_shared/_scripts.toml` - script source definitions (Makefile, package.json)
159-
- `_shared/_resources.toml` - project resource file patterns (README, LICENSE, etc.)
159+
- `_shared/_resources.toml` - repository document patterns (README, LICENSE, CODEOWNERS, FUNDING, etc.)
160160
- `_shared/_layout.toml` - source and test directory patterns
161161
- `_shared/_style.toml` - style config files and inference settings
162162
- `_shared/_runtimes.toml` - runtime version file patterns
163163
- `_shared/_manifests.toml` - manifest file list for dependency detection
164164
- `_shared/_ci.toml` - CI matrix parsing configuration
165+
166+
A resource entry in `_resources.toml` looks like this:
167+
168+
```toml
169+
[[resources]]
170+
[resources.resource]
171+
name = "Contributing"
172+
field = "contributing"
173+
group = "community"
174+
patterns = ["contributing", "contributing.md", "contributing.txt", "contributing.rst"]
175+
dirs = ["docs", ".github", ".gitlab"]
176+
```
177+
178+
`field` is the JSON key the path is written to. `group` places it under one of `legal`, `community`, `security`, or `metadata`; omit it for top-level fields like `readme` and `license`. `patterns` are matched case-insensitively against directory listings, so list each pattern once in lowercase. List explicit extensions rather than a trailing glob for prose files so `support.md` matches but `docs/Support-Tiers.md` does not. `dirs` lists extra directories to search after the repo root; root always wins on a tie.

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ Layout: cmd/
9696
OS: ubuntu-latest, macos-latest, windows-latest (CI matrix)
9797
9898
Resources: README.md
99-
Resources: CONTRIBUTING.md
10099
Resources: LICENSE (MIT)
100+
Community: CONTRIBUTING.md
101101
102102
Git: branch main 71 commits
103103
origin: git@github.com:user/myproject.git
@@ -241,6 +241,12 @@ brief enrich --verbose .
241241

242242
Data sources: [ecosyste.ms](https://ecosyste.ms) for published package metadata, [endoflife.date](https://endoflife.date) for runtime lifecycle, [OpenSSF Scorecard](https://securityscorecards.dev) for repo security.
243243

244+
## Resources
245+
246+
Alongside the toolchain, brief picks up the conventional documents that describe how a project is run. README, changelog, roadmap, license (with detected SPDX identifier), and agent instructions are reported at the top level. Everything else is grouped: legal covers copyright, NOTICE, DCO, and CLA files; community covers contributing guides, code of conduct, support, governance, maintainers, authors, CODEOWNERS, and DEI statements; security covers the security policy, threat model, and audit reports; metadata covers machine-readable files like FUNDING, CITATION.cff, publiccode.yml, codemeta.json, and .zenodo.json.
247+
248+
Matching is case-insensitive and checks the repo root first, then `docs/`, `.github/`, and `.gitlab/`. Paths in the output are repo-relative, so a funding file found under `.github/` is reported as `.github/FUNDING.yml` rather than just the basename. In JSON the groups appear as nested objects under `resources.legal`, `resources.community`, `resources.security`, and `resources.metadata`.
249+
244250
<!-- brief:tools:start (generated by: brief list -readme tools) -->
245251
## What it detects
246252

brief.go

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,56 @@ type PlatformInfo struct {
9999
CIMatrixOS []string `json:"ci_matrix_os,omitempty"`
100100
}
101101

102-
// ResourceInfo describes project resource files.
102+
// ResourceInfo describes project resource files. Paths are relative to the
103+
// repository root.
103104
type ResourceInfo struct {
104-
Readme string `json:"readme,omitempty"`
105-
Contributing string `json:"contributing,omitempty"`
106-
Changelog string `json:"changelog,omitempty"`
107-
License string `json:"license,omitempty"`
108-
LicenseType string `json:"license_type,omitempty"`
109-
Security string `json:"security,omitempty"`
105+
Readme string `json:"readme,omitempty"`
106+
Changelog string `json:"changelog,omitempty"`
107+
Roadmap string `json:"roadmap,omitempty"`
108+
License string `json:"license,omitempty"`
109+
LicenseType string `json:"license_type,omitempty"`
110+
Agents string `json:"agents,omitempty"`
111+
112+
Legal map[string]string `json:"legal,omitempty"`
113+
Community map[string]string `json:"community,omitempty"`
114+
Security map[string]string `json:"security,omitempty"`
115+
Metadata map[string]string `json:"metadata,omitempty"`
116+
}
117+
118+
// Group returns the map for the named resource group, creating it if needed.
119+
// Returns nil for unknown group names.
120+
func (r *ResourceInfo) Group(name string) map[string]string {
121+
switch name {
122+
case "legal":
123+
if r.Legal == nil {
124+
r.Legal = map[string]string{}
125+
}
126+
return r.Legal
127+
case "community":
128+
if r.Community == nil {
129+
r.Community = map[string]string{}
130+
}
131+
return r.Community
132+
case "security":
133+
if r.Security == nil {
134+
r.Security = map[string]string{}
135+
}
136+
return r.Security
137+
case "metadata":
138+
if r.Metadata == nil {
139+
r.Metadata = map[string]string{}
140+
}
141+
return r.Metadata
142+
}
143+
return nil
144+
}
145+
146+
// Empty reports whether no resources were found.
147+
func (r *ResourceInfo) Empty() bool {
148+
return r.Readme == "" && r.Changelog == "" && r.Roadmap == "" &&
149+
r.License == "" && r.Agents == "" &&
150+
len(r.Legal) == 0 && len(r.Community) == 0 &&
151+
len(r.Security) == 0 && len(r.Metadata) == 0
110152
}
111153

112154
// GitInfo describes the git repository state.

detect/detect.go

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"os"
1010
"os/exec"
11+
"path"
1112
"path/filepath"
1213
"sort"
1314
"strings"
@@ -47,6 +48,7 @@ type Engine struct {
4748

4849
// Lazily populated caches
4950
fileExts map[string]int // cached file extension counts in the project
51+
dirCache map[string][]string
5052
depsLoaded bool
5153
runtimeDeps map[string]bool // all runtime/unscoped dependency names
5254
devDeps map[string]bool // development/test/build dependency names
@@ -1002,38 +1004,86 @@ func (e *Engine) detectResources() *brief.ResourceInfo {
10021004
}
10031005

10041006
res := &brief.ResourceInfo{}
1005-
found := false
10061007

10071008
for _, rd := range e.KB.Resources {
1008-
for _, pattern := range rd.Resource.Patterns {
1009-
if matches := e.globMatch(pattern); len(matches) > 0 {
1010-
found = true
1011-
match := filepath.Base(matches[0])
1012-
switch rd.Resource.Field {
1013-
case "readme":
1014-
res.Readme = match
1015-
case "contributing":
1016-
res.Contributing = match
1017-
case "changelog":
1018-
res.Changelog = match
1019-
case "license":
1020-
res.License = match
1021-
res.LicenseType = detectLicenseType(matches[0])
1022-
case "security":
1023-
res.Security = match
1024-
}
1025-
break
1009+
abs, rel := e.findResource(rd.Resource)
1010+
if rel == "" {
1011+
continue
1012+
}
1013+
if rd.Resource.Group != "" {
1014+
if g := res.Group(rd.Resource.Group); g != nil {
1015+
g[rd.Resource.Field] = rel
10261016
}
1017+
continue
1018+
}
1019+
switch rd.Resource.Field {
1020+
case "readme":
1021+
res.Readme = rel
1022+
case "changelog":
1023+
res.Changelog = rel
1024+
case "roadmap":
1025+
res.Roadmap = rel
1026+
case "license":
1027+
res.License = rel
1028+
res.LicenseType = detectLicenseType(abs)
1029+
case "agents":
1030+
res.Agents = rel
10271031
}
10281032
}
10291033

1030-
if !found {
1034+
if res.Empty() {
10311035
return nil
10321036
}
1033-
10341037
return res
10351038
}
10361039

1040+
// findResource searches for the first file matching any of the resource's
1041+
// patterns, in the repo root and then each configured subdirectory. Matching
1042+
// is case-insensitive. It returns the absolute path and the path relative to
1043+
// the repo root.
1044+
func (e *Engine) findResource(r kb.ResourceInfo) (abs, rel string) {
1045+
dirs := append([]string{"."}, r.Dirs...)
1046+
for _, dir := range dirs {
1047+
entries := e.dirFiles(dir)
1048+
for _, pattern := range r.Patterns {
1049+
lp := strings.ToLower(pattern)
1050+
for _, name := range entries {
1051+
if ok, _ := filepath.Match(lp, strings.ToLower(name)); !ok {
1052+
continue
1053+
}
1054+
relPath := name
1055+
if dir != "." {
1056+
relPath = path.Join(dir, name)
1057+
}
1058+
return filepath.Join(e.Root, filepath.FromSlash(relPath)), relPath
1059+
}
1060+
}
1061+
}
1062+
return "", ""
1063+
}
1064+
1065+
// dirFiles returns the regular file names in dir (relative to e.Root),
1066+
// caching results per directory.
1067+
func (e *Engine) dirFiles(dir string) []string {
1068+
if e.dirCache == nil {
1069+
e.dirCache = map[string][]string{}
1070+
}
1071+
if cached, ok := e.dirCache[dir]; ok {
1072+
return cached
1073+
}
1074+
var names []string
1075+
entries, err := os.ReadDir(filepath.Join(e.Root, filepath.FromSlash(dir)))
1076+
if err == nil {
1077+
for _, ent := range entries {
1078+
if !ent.IsDir() {
1079+
names = append(names, ent.Name())
1080+
}
1081+
}
1082+
}
1083+
e.dirCache[dir] = names
1084+
return names
1085+
}
1086+
10371087
// detectPlatforms checks for runtime version files and CI matrices.
10381088
func (e *Engine) detectPlatforms() *brief.PlatformInfo {
10391089
platforms := &brief.PlatformInfo{
@@ -1161,15 +1211,6 @@ func toStringSlice(v any) []string {
11611211
}
11621212
}
11631213

1164-
// globMatch returns files matching a glob pattern relative to the project root.
1165-
func (e *Engine) globMatch(pattern string) []string {
1166-
matches, err := filepath.Glob(filepath.Join(e.Root, pattern))
1167-
if err != nil {
1168-
return nil
1169-
}
1170-
return matches
1171-
}
1172-
11731214
// detectGit extracts git repository metadata by shelling out to git.
11741215
// Returns nil if git is not installed or the directory is not a git repo.
11751216
func (e *Engine) detectGit(absPath string) *brief.GitInfo {

detect/detect_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,114 @@ func TestRubyResources(t *testing.T) {
119119
}
120120
}
121121

122+
func TestResourceGroups(t *testing.T) {
123+
dir := t.TempDir()
124+
touch := func(p string) {
125+
full := filepath.Join(dir, p)
126+
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
127+
t.Fatal(err)
128+
}
129+
if err := os.WriteFile(full, []byte("x"), 0o644); err != nil {
130+
t.Fatal(err)
131+
}
132+
}
133+
touch("README.md")
134+
touch("CHANGELOG.md")
135+
touch("AGENTS.md")
136+
touch("NOTICE")
137+
touch("CONTRIBUTING.md")
138+
touch(".github/CODE_OF_CONDUCT.md")
139+
touch(".github/CODEOWNERS")
140+
touch(".github/FUNDING.yml")
141+
touch("docs/SECURITY.md")
142+
touch("CITATION.cff")
143+
144+
engine := New(loadKB(t), dir)
145+
r, err := engine.Run()
146+
if err != nil {
147+
t.Fatalf("Run: %v", err)
148+
}
149+
res := r.Resources
150+
if res == nil {
151+
t.Fatal("expected resources")
152+
}
153+
154+
if res.Readme != "README.md" {
155+
t.Errorf("readme = %q", res.Readme)
156+
}
157+
if res.Agents != "AGENTS.md" {
158+
t.Errorf("agents = %q", res.Agents)
159+
}
160+
if res.Legal["notice"] != "NOTICE" {
161+
t.Errorf("legal.notice = %q", res.Legal["notice"])
162+
}
163+
if res.Community["contributing"] != "CONTRIBUTING.md" {
164+
t.Errorf("community.contributing = %q", res.Community["contributing"])
165+
}
166+
if res.Community["code_of_conduct"] != ".github/CODE_OF_CONDUCT.md" {
167+
t.Errorf("community.code_of_conduct = %q", res.Community["code_of_conduct"])
168+
}
169+
if res.Community["codeowners"] != ".github/CODEOWNERS" {
170+
t.Errorf("community.codeowners = %q", res.Community["codeowners"])
171+
}
172+
if res.Security["policy"] != "docs/SECURITY.md" {
173+
t.Errorf("security.policy = %q", res.Security["policy"])
174+
}
175+
if res.Metadata["funding"] != ".github/FUNDING.yml" {
176+
t.Errorf("metadata.funding = %q", res.Metadata["funding"])
177+
}
178+
if res.Metadata["citation"] != "CITATION.cff" {
179+
t.Errorf("metadata.citation = %q", res.Metadata["citation"])
180+
}
181+
}
182+
183+
func TestResourceCaseInsensitive(t *testing.T) {
184+
dir := t.TempDir()
185+
for _, p := range []string{"ReadMe.rst", "Security.MD", ".github/Code_Of_Conduct.md"} {
186+
full := filepath.Join(dir, p)
187+
_ = os.MkdirAll(filepath.Dir(full), 0o755)
188+
if err := os.WriteFile(full, []byte("x"), 0o644); err != nil {
189+
t.Fatal(err)
190+
}
191+
}
192+
engine := New(loadKB(t), dir)
193+
r, err := engine.Run()
194+
if err != nil {
195+
t.Fatalf("Run: %v", err)
196+
}
197+
if r.Resources == nil {
198+
t.Fatal("expected resources")
199+
}
200+
if r.Resources.Readme != "ReadMe.rst" {
201+
t.Errorf("readme = %q", r.Resources.Readme)
202+
}
203+
if r.Resources.Security["policy"] != "Security.MD" {
204+
t.Errorf("security.policy = %q", r.Resources.Security["policy"])
205+
}
206+
if r.Resources.Community["code_of_conduct"] != ".github/Code_Of_Conduct.md" {
207+
t.Errorf("code_of_conduct = %q", r.Resources.Community["code_of_conduct"])
208+
}
209+
}
210+
211+
func TestResourceRootBeatsSubdir(t *testing.T) {
212+
dir := t.TempDir()
213+
for _, p := range []string{"CONTRIBUTING.md", ".github/CONTRIBUTING.md"} {
214+
full := filepath.Join(dir, p)
215+
_ = os.MkdirAll(filepath.Dir(full), 0o755)
216+
if err := os.WriteFile(full, []byte("x"), 0o644); err != nil {
217+
t.Fatal(err)
218+
}
219+
}
220+
engine := New(loadKB(t), dir)
221+
r, err := engine.Run()
222+
if err != nil {
223+
t.Fatalf("Run: %v", err)
224+
}
225+
if got := r.Resources.Community["contributing"]; got != "CONTRIBUTING.md" {
226+
t.Errorf("expected root CONTRIBUTING.md to win, got %q", got)
227+
}
228+
}
229+
122230
func TestRubyPlatforms(t *testing.T) {
123231
r := rubyReport(t)
124232
if r.Platforms == nil {

0 commit comments

Comments
 (0)