feat(discovery): frontmatter guard for flat agents/commands docs
A flat .md under agents/ or commands/ whose frontmatter contains none of the definition keys (name, description, mode, agent, model, argument-hint, temperature, permission) is documentation, not an item: discovery marks it Skipped with a recorded warning — it never enters a selection, inventory, availability listing or collision index, but the warning lands in the lockfile and add/update/select reports like other discovery warnings. Skills keep their folder+SKILL.md marker, MCP keeps the .yaml marker. README gains the canonical layout table and the migration note for folder-style agent repos. Closes beads: agent-lib-2n7
This commit is contained in:
@@ -36,6 +36,11 @@ type Item struct {
|
||||
RelPath string
|
||||
Frontmatter *Frontmatter
|
||||
Warnings []string
|
||||
// Skipped marks a file discovery recognized but deliberately excluded —
|
||||
// e.g. a documentation .md under agents/ or commands/ without definition
|
||||
// frontmatter. Skipped items carry warnings but must never enter a
|
||||
// selection, inventory or availability listing.
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// Scan discovers items of all four content types under cfg. Discovery is a
|
||||
@@ -156,6 +161,12 @@ func scanFlatFiles(tree Tree, fileEntries map[string]map[string]bool, root, typ
|
||||
continue
|
||||
}
|
||||
it := Item{Type: typ, UpstreamID: id, RelPath: rel}
|
||||
if !HasDefinitionFrontmatter(data) {
|
||||
it.Skipped = true
|
||||
it.Warnings = append(it.Warnings, fmt.Sprintf("%s: no %s frontmatter, skipped", rel, strings.TrimSuffix(typ, "s")))
|
||||
items = append(items, it)
|
||||
continue
|
||||
}
|
||||
fm, warnings := ParseFrontmatter(data)
|
||||
it.Frontmatter = &fm
|
||||
for _, w := range warnings {
|
||||
|
||||
@@ -20,16 +20,11 @@ type Frontmatter struct {
|
||||
func ParseFrontmatter(data []byte) (Frontmatter, []string) {
|
||||
var fm Frontmatter
|
||||
var warnings []string
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
lines, fenced, closed := parseFence(data)
|
||||
if !fenced {
|
||||
return fm, nil
|
||||
}
|
||||
closed := false
|
||||
for _, line := range lines[1:] {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
@@ -58,6 +53,52 @@ func ParseFrontmatter(data []byte) (Frontmatter, []string) {
|
||||
return fm, warnings
|
||||
}
|
||||
|
||||
// definitionKeys mark a flat .md under agents/ or commands/ as an intentional
|
||||
// agent/command definition rather than dropped documentation.
|
||||
var definitionKeys = map[string]bool{
|
||||
"name": true,
|
||||
"description": true,
|
||||
"mode": true,
|
||||
"agent": true,
|
||||
"model": true,
|
||||
"argument-hint": true,
|
||||
"temperature": true,
|
||||
"permission": true,
|
||||
}
|
||||
|
||||
// HasDefinitionFrontmatter reports whether data's leading frontmatter block
|
||||
// contains any key that marks a flat .md as an agent/command definition.
|
||||
func HasDefinitionFrontmatter(data []byte) bool {
|
||||
lines, fenced, _ := parseFence(data)
|
||||
if !fenced {
|
||||
return false
|
||||
}
|
||||
for _, line := range lines {
|
||||
key, _, ok := strings.Cut(strings.TrimSpace(line), ":")
|
||||
if ok && definitionKeys[strings.ToLower(strings.TrimSpace(key))] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseFence returns the lines between the leading `---` fences: the block
|
||||
// lines, whether the file is fenced at all, and whether the fence closes.
|
||||
func parseFence(data []byte) (lines []string, fenced, closed bool) {
|
||||
all := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
if len(all) == 0 || strings.TrimSpace(all[0]) != "---" {
|
||||
return nil, false, false
|
||||
}
|
||||
fenced = true
|
||||
for _, line := range all[1:] {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
return lines, fenced, true
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines, fenced, false
|
||||
}
|
||||
|
||||
func splitList(v string) []string {
|
||||
if v == "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHasDefinitionFrontmatter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{name: "no frontmatter at all", in: "# Schema docs\nplain documentation\n", want: false},
|
||||
{name: "empty fence", in: "---\n---\nbody\n", want: false},
|
||||
{name: "only unknown keys", in: "---\ntitle: Schema\nlayout: docs\n---\n", want: false},
|
||||
{name: "name key", in: "---\nname: Scout\n---\nbody\n", want: true},
|
||||
{name: "description key", in: "---\ndescription: scouts things\n---\n", want: true},
|
||||
{name: "mode key", in: "---\nmode: primary\n---\n", want: true},
|
||||
{name: "model key", in: "---\nmodel: sonnet\n---\n", want: true},
|
||||
{name: "argument-hint key", in: "---\nargument-hint: [msg]\n---\n", want: true},
|
||||
{name: "temperature key", in: "---\ntemperature: 0.2\n---\n", want: true},
|
||||
{name: "permission key", in: "---\npermission: edit\n---\n", want: true},
|
||||
{name: "mixed unknown and known", in: "---\ntitle: X\ndescription: real agent\n---\n", want: true},
|
||||
{name: "unclosed block still counts keys", in: "---\nname: Broken\n", want: true},
|
||||
{name: "case-insensitive keys", in: "---\nDescription: Y\n---\n", want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := HasDefinitionFrontmatter([]byte(tc.in)); got != tc.want {
|
||||
t.Errorf("HasDefinitionFrontmatter(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSkipsDocMarkdownInFlatDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTree(t, dir, map[string]string{
|
||||
"agents/scout.md": "---\ndescription: a real agent\n---\nbody\n",
|
||||
"agents/SCHEMA.md": "# Schema\npure documentation, no frontmatter\n",
|
||||
"commands/review.md": "---\nargument-hint: [files]\n---\nbody\n",
|
||||
"commands/README.md": "Documentation for the commands directory.\n",
|
||||
"commands/notes.md": "---\ntitle: Notes\n---\nunknown keys only\n",
|
||||
"skills/good/SKILL.md": "---\nname: Good\n---\n# Good\n",
|
||||
})
|
||||
items, err := Scan(&FsTree{Root: dir}, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
skippedWarnings := []string{}
|
||||
for _, it := range items {
|
||||
if it.Skipped {
|
||||
skippedWarnings = append(skippedWarnings, it.Warnings...)
|
||||
continue
|
||||
}
|
||||
got[it.Type+"/"+it.UpstreamID] = true
|
||||
}
|
||||
for _, want := range []string{"agents/scout", "commands/review", "skills/good"} {
|
||||
if !got[want] {
|
||||
t.Errorf("%s must still be discovered; got %v", want, got)
|
||||
}
|
||||
}
|
||||
for _, doc := range []string{"agents/SCHEMA", "commands/README", "commands/notes"} {
|
||||
if got[doc] {
|
||||
t.Errorf("%s is documentation and must be skipped; got %v", doc, got)
|
||||
}
|
||||
}
|
||||
joined := strings.Join(skippedWarnings, "\n")
|
||||
for _, want := range []string{
|
||||
"agents/SCHEMA.md: no agent frontmatter, skipped",
|
||||
"commands/README.md: no command frontmatter, skipped",
|
||||
"commands/notes.md: no command frontmatter, skipped",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("skip warning %q missing; got %v", want, skippedWarnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func docFixture(h *harness, t *testing.T) {
|
||||
t.Helper()
|
||||
h.write(t, "skills/good-skill/SKILL.md", "---\nname: Good Skill\ndescription: works\n---\n# Good\n")
|
||||
h.write(t, "agents/scout.md", "---\ndescription: a real agent\n---\nbody\n")
|
||||
h.write(t, "agents/SCHEMA.md", "# Schema\npure documentation, no frontmatter\n")
|
||||
h.write(t, "commands/review.md", "---\nargument-hint: [files]\n---\nbody\n")
|
||||
h.write(t, "commands/README.md", "Documentation for the commands directory.\n")
|
||||
h.commitUpstream(t, "fixture with documentation files")
|
||||
}
|
||||
|
||||
func TestVendorAddSkipsDocMarkdownWithWarning(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
docFixture(h, t)
|
||||
|
||||
out := h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
|
||||
if !contains(out, "agents/SCHEMA.md: no agent frontmatter, skipped") {
|
||||
t.Errorf("add report must warn about skipped SCHEMA.md:\n%s", out)
|
||||
}
|
||||
if !contains(out, "commands/README.md: no command frontmatter, skipped") {
|
||||
t.Errorf("add report must warn about skipped README.md:\n%s", out)
|
||||
}
|
||||
|
||||
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
|
||||
if !contains(lock, "agents/SCHEMA.md: no agent frontmatter, skipped") {
|
||||
t.Errorf("lockfile warnings must record the skip:\n%s", lock)
|
||||
}
|
||||
for _, dir := range []string{"agents", "commands"} {
|
||||
entries, err := os.ReadDir(filepath.Join(h.workDir, "external", "superpowers", dir))
|
||||
if err != nil {
|
||||
t.Fatalf("external %s area must exist: %v", dir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if (dir == "agents" && name == "SCHEMA.md") || (dir == "commands" && name == "README.md") {
|
||||
t.Errorf("documentation file %s/%s must not be materialized", dir, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !contains(out, "1 agents") || !contains(out, "1 commands") {
|
||||
t.Errorf("counts must reflect real items only:\n%s", out)
|
||||
}
|
||||
h.mustRun(t, "validate")
|
||||
}
|
||||
|
||||
func TestVendorAddIncludeErrorOmitsSkippedDocs(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
docFixture(h, t)
|
||||
|
||||
out, err := h.run(t, "vendor", "add", "superpowers", h.upstreamDir, "--include", "typo")
|
||||
if err == nil {
|
||||
t.Fatalf("include with typo must fail:\n%s", out)
|
||||
}
|
||||
if contains(out, "SCHEMA") || contains(out, "README") {
|
||||
t.Errorf("skipped documentation must not appear as available ids:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"scout", "review", "good-skill"} {
|
||||
if !contains(out, want) {
|
||||
t.Errorf("real ids must still be listed as available (missing %s):\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorUpdateSelectKeepSkippingDocs(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
docFixture(h, t)
|
||||
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir, "--include", "scout,review")
|
||||
|
||||
out := h.mustRun(t, "vendor", "select", "superpowers", "--add", "good-skill")
|
||||
if contains(out, "SCHEMA") {
|
||||
t.Errorf("select report must not surface skipped docs:\n%s", out)
|
||||
}
|
||||
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
|
||||
if got := strings.Count(lock, "no agent frontmatter, skipped"); got != 1 {
|
||||
t.Errorf("warning must stay recorded exactly once in the lockfile, got %d", got)
|
||||
}
|
||||
h.mustRun(t, "validate")
|
||||
}
|
||||
Vendored
+16
-3
@@ -150,7 +150,9 @@ func discoveryFromTreeRoot(root string) lockfile.Discovery {
|
||||
func checkIncludeEntries(items []discovery.Item, include []string, name string) error {
|
||||
available := map[string]bool{}
|
||||
for _, it := range items {
|
||||
available[it.UpstreamID] = true
|
||||
if !it.Skipped {
|
||||
available[it.UpstreamID] = true
|
||||
}
|
||||
}
|
||||
for _, want := range include {
|
||||
if !available[want] {
|
||||
@@ -171,6 +173,9 @@ func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.
|
||||
}
|
||||
var out []discovery.Item
|
||||
for _, it := range items {
|
||||
if it.Skipped {
|
||||
continue
|
||||
}
|
||||
switch sel.Mode {
|
||||
case lockfile.ModeInclude:
|
||||
if include[it.UpstreamID] {
|
||||
@@ -216,10 +221,18 @@ func materialize(workDir, name string, tree discovery.Tree, selected []discovery
|
||||
}
|
||||
|
||||
func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selected []discovery.Item) {
|
||||
real := 0
|
||||
for _, it := range discovered {
|
||||
if !it.Skipped {
|
||||
real++
|
||||
}
|
||||
}
|
||||
counts := func(items []discovery.Item) string {
|
||||
per := map[string]int{}
|
||||
for _, it := range items {
|
||||
per[it.Type]++
|
||||
if !it.Skipped {
|
||||
per[it.Type]++
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
for _, t := range lockfile.Types {
|
||||
@@ -236,7 +249,7 @@ func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selec
|
||||
fmt.Fprintf(w, " url: %s\n", src.URL)
|
||||
fmt.Fprintf(w, " ref: %s\n", src.Ref)
|
||||
fmt.Fprintf(w, " rev: %s\n", src.Rev)
|
||||
fmt.Fprintf(w, "discovered %d items (%s)\n", len(discovered), counts(discovered))
|
||||
fmt.Fprintf(w, "discovered %d items (%s)\n", real, counts(discovered))
|
||||
fmt.Fprintf(w, "selected %d items (%s)\n", len(selected), counts(selected))
|
||||
fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name))
|
||||
if len(src.Warnings) > 0 {
|
||||
|
||||
Vendored
+3
@@ -39,6 +39,9 @@ func ownDeployed(workDir string) (deployedIndex, error) {
|
||||
}
|
||||
idx := deployedIndex{}
|
||||
for _, it := range items {
|
||||
if it.Skipped {
|
||||
continue
|
||||
}
|
||||
idx.add(it.Type, it.UpstreamID, owner{source: "own", id: it.UpstreamID})
|
||||
}
|
||||
return idx, nil
|
||||
|
||||
Vendored
+1
-1
@@ -226,7 +226,7 @@ func availableSince(old map[string]map[string]bool, items []discovery.Item, sele
|
||||
inSelection := idSet(selected)
|
||||
out := map[string][]string{}
|
||||
for _, it := range items {
|
||||
if old[it.Type][it.UpstreamID] || inSelection[it.Type][it.UpstreamID] {
|
||||
if it.Skipped || old[it.Type][it.UpstreamID] || inSelection[it.Type][it.UpstreamID] {
|
||||
continue
|
||||
}
|
||||
out[it.Type] = append(out[it.Type], it.UpstreamID)
|
||||
|
||||
Reference in New Issue
Block a user