feat: replace category with a multi-facet tag vocabulary

A single category could not describe tools that ship as a CLI, an editor
plugin and a desktop app at once, and 7 of the 40 entries were filed
under a surface they only partly match — cline is "an SDK, IDE extension
or CLI assistant" in one `extension` slot, Reasonix ships CLI, desktop
and VS Code under `cli`, warp is a terminal filed as `ide`.

Tags cover five facets: surface (at least one), model access, workflow,
integration, and origin (at most one). tagVocabulary in validate.go is
the single source of truth — the validator, its error messages, and the
dashboard's filter chips all derive from it, the last via a new facets
field in site/data.json.

The dashboard now filters on tags with multi-select chips grouped by
facet: OR within a facet, AND across facets, plus a clear-filters
control, and search matches tags as well as name and description. Only
tags some row actually carries get a chip, so chips and rows cannot
disagree. Tag pills are tinted per facet, replacing the category badge.

All 40 entries are tagged from each repo's own README, topics and docs;
CONTRIBUTING documents every tag and the evidence rule for applying one.
A leftover `category:` key now fails validation with a message naming
its replacement, rather than being silently ignored.

README.md and data/history.jsonl are the regenerated updater output.
This commit is contained in:
tiennm99 committed 2026-09-11 15:40:18 +07:00
1 parent e49c487700
commit eb12bcc2cb
16 files changed
+462 -205

No files matched your search

+65 -33
View File
@@ -8,9 +8,9 @@ import (
func TestValidateAgents_ValidEntriesNoViolations(t *testing.T) {
agents := []Agent{
{Owner: "aider-ai", Repo: "aider", Category: "cli"},
{Owner: "cline", Repo: "cline", Category: "extension"},
{Owner: "a", Repo: "b.c-d_e", Category: "web"},
{Owner: "aider-ai", Repo: "aider", Tags: []string{"terminal", "community"}},
{Owner: "cline", Repo: "cline", Tags: []string{"terminal", "community"}},
{Owner: "a", Repo: "b.c-d_e", Tags: []string{"terminal", "community"}},
}
violations := validateAgents(agents)
if len(violations) != 0 {
@@ -19,7 +19,7 @@ func TestValidateAgents_ValidEntriesNoViolations(t *testing.T) {
}
func TestValidateAgents_EmptyOwner(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "", Repo: "repo1", Category: "cli"}})
violations := validateAgents([]Agent{{Owner: "", Repo: "repo1", Tags: []string{"terminal", "community"}}})
if !anyContains(violations, "owner is empty") {
t.Errorf("expected 'owner is empty' violation, got %v", violations)
}
@@ -29,7 +29,7 @@ func TestValidateAgents_InvalidOwnerChars(t *testing.T) {
tests := []string{"foo_bar", "-leading", "trailing-", "double--hyphen", "foo owner"}
for _, owner := range tests {
t.Run(owner, func(t *testing.T) {
violations := validateAgents([]Agent{{Owner: owner, Repo: "repo1", Category: "cli"}})
violations := validateAgents([]Agent{{Owner: owner, Repo: "repo1", Tags: []string{"terminal", "community"}}})
if !anyContains(violations, "does not look like a valid GitHub username/org") {
t.Errorf("owner %q: expected invalid-owner violation, got %v", owner, violations)
}
@@ -38,7 +38,7 @@ func TestValidateAgents_InvalidOwnerChars(t *testing.T) {
}
func TestValidateAgents_EmptyRepo(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "", Category: "cli"}})
violations := validateAgents([]Agent{{Owner: "org", Repo: "", Tags: []string{"terminal", "community"}}})
if !anyContains(violations, "repo is empty") {
t.Errorf("expected 'repo is empty' violation, got %v", violations)
}
@@ -48,7 +48,7 @@ func TestValidateAgents_InvalidRepoChars(t *testing.T) {
tests := []string{"foo/bar", "foo bar", "foo@bar", "foo#bar"}
for _, repo := range tests {
t.Run(repo, func(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: repo, Category: "cli"}})
violations := validateAgents([]Agent{{Owner: "org", Repo: repo, Tags: []string{"terminal", "community"}}})
if !anyContains(violations, "characters not allowed in a GitHub repo name") {
t.Errorf("repo %q: expected invalid-repo violation, got %v", repo, violations)
}
@@ -56,35 +56,67 @@ func TestValidateAgents_InvalidRepoChars(t *testing.T) {
}
}
func TestValidateAgents_MissingCategory(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Category: ""}})
if !anyContains(violations, "category is required") {
t.Errorf("expected 'category is required' violation, got %v", violations)
func TestValidateAgents_LegacyCategoryFieldIsRejected(t *testing.T) {
// An entry left on the retired schema must fail loudly, not be ignored.
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Category: "cli", Tags: []string{"terminal"}}})
if !anyContains(violations, "no longer a field") {
t.Errorf("expected a legacy-category violation, got %v", violations)
}
}
func TestValidateAgents_InvalidCategory(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Category: "framework"}})
if !anyContains(violations, "is not one of cli, ide, extension, library, research, web") {
t.Errorf("expected invalid-category violation, got %v", violations)
func TestValidateAgents_MissingSurfaceTag(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Tags: []string{"byo-model", "community"}}})
if !anyContains(violations, "at least one surface tag") {
t.Errorf("expected a missing-surface violation, got %v", violations)
}
}
func TestValidateAgents_AllValidCategoriesAccepted(t *testing.T) {
for _, cat := range []string{"cli", "ide", "extension", "library", "research", "web"} {
t.Run(cat, func(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Category: cat}})
if len(violations) != 0 {
t.Errorf("category %q: expected no violations, got %v", cat, violations)
}
})
func TestValidateAgents_NoTagsAtAll(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1"}})
if !anyContains(violations, "at least one surface tag") {
t.Errorf("expected a missing-surface violation, got %v", violations)
}
}
func TestValidateAgents_UnknownTag(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Tags: []string{"terminal", "gpt-4"}}})
if !anyContains(violations, `tag "gpt-4" is not in the vocabulary`) {
t.Errorf("expected an unknown-tag violation, got %v", violations)
}
}
func TestValidateAgents_RepeatedTag(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Tags: []string{"terminal", "terminal"}}})
if !anyContains(violations, `tag "terminal" is repeated`) {
t.Errorf("expected a repeated-tag violation, got %v", violations)
}
}
func TestValidateAgents_TwoOriginTags(t *testing.T) {
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Tags: []string{"terminal", "vendor", "community"}}})
if !anyContains(violations, "origin tags, expected at most one") {
t.Errorf("expected a multiple-origin violation, got %v", violations)
}
}
func TestValidateAgents_EveryVocabularyTagAccepted(t *testing.T) {
// Each legal tag must pass alongside a surface tag, so a typo in
// tagFacets can't silently make a documented tag unusable.
for tag := range tagFacetIDs {
tags := []string{"terminal", tag}
if tag == "terminal" {
tags = []string{"terminal"}
}
if violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Tags: tags}}); len(violations) > 0 {
t.Errorf("tag %q: expected no violations, got %v", tag, violations)
}
}
}
func TestValidateAgents_DuplicateCaseInsensitive(t *testing.T) {
agents := []Agent{
{Owner: "Foo", Repo: "Bar", Category: "cli"},
{Owner: "foo", Repo: "bar", Category: "cli"},
{Owner: "Foo", Repo: "Bar", Tags: []string{"terminal", "community"}},
{Owner: "foo", Repo: "bar", Tags: []string{"terminal", "community"}},
}
violations := validateAgents(agents)
if !anyContains(violations, "duplicate of entry 0") {
@@ -94,8 +126,8 @@ func TestValidateAgents_DuplicateCaseInsensitive(t *testing.T) {
func TestValidateAgents_NoDuplicateForDistinctRepos(t *testing.T) {
agents := []Agent{
{Owner: "foo", Repo: "bar", Category: "cli"},
{Owner: "foo", Repo: "baz", Category: "cli"},
{Owner: "foo", Repo: "bar", Tags: []string{"terminal", "community"}},
{Owner: "foo", Repo: "baz", Tags: []string{"terminal", "community"}},
}
violations := validateAgents(agents)
if len(violations) != 0 {
@@ -105,14 +137,14 @@ func TestValidateAgents_NoDuplicateForDistinctRepos(t *testing.T) {
func TestValidateAgents_CollectsAllViolationsNotJustFirst(t *testing.T) {
agents := []Agent{
{Owner: "", Repo: "", Category: ""},
{Owner: "org", Repo: "repo1", Category: "not-a-category"},
{Owner: "", Repo: "", Tags: nil},
{Owner: "org", Repo: "repo1", Tags: []string{"terminal", "nonsense", "vendor", "community"}},
}
violations := validateAgents(agents)
// entry 0: owner empty, repo empty, category empty = 3 violations.
// entry 1: invalid category = 1 violation.
if len(violations) != 4 {
t.Errorf("expected 4 violations collected across both entries, got %d: %v", len(violations), violations)
// entry 0: owner empty, repo empty, no surface tag = 3 violations.
// entry 1: unknown tag, two origin tags = 2 violations.
if len(violations) != 5 {
t.Errorf("expected 5 violations collected across both entries, got %d: %v", len(violations), violations)
}
}