feat(vendor): --exclude plus content-type selection (--types/--exclude-types)
vendor add gains --exclude a,b,c (all mode minus those ids) as the mirror of --include, for taking the majority of a source while dropping a few items. Mutual exclusion with --include is a hard error, unknown ids fail naming the available ones (generalized checkIDEntries). vendor add and vendor select gain --types (allow-list) and --exclude-types (deny-list) over the four content types. The filter is stored as the optional, v2-compatible Selection.exclude_types field and survives vendor update: new upstream items of an excluded type never enter the work repository (unlike id expansion, which would silently re-admit them). --types/--exclude-types replace the filter on select, absent flags keep it, and --mode all clears it along with include/exclude. Also fixes materializeTree to create the staging directory for an empty selection — previously select-to-zero failed at the staging rename.
This commit is contained in:
1 parent
1ab97bff43
commit
0c779bc970
9 files changed
+533
-50
No files matched your search
@@ -0,0 +1,75 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVendorAddExcludeMode pins --exclude: all mode minus the listed ids,
|
||||
// materialized and recorded in the lockfile as mode "all" with an exclude
|
||||
// list.
|
||||
func TestVendorAddExcludeMode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
standardFixture(h, t)
|
||||
|
||||
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--exclude", "broken-skill,scout")
|
||||
|
||||
for _, p := range []string{
|
||||
"external/curated/skills/good-skill/SKILL.md",
|
||||
"external/curated/commands/review.md",
|
||||
"external/curated/mcp/search.yaml",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil {
|
||||
t.Errorf("non-excluded item must be materialized: %s", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{
|
||||
"external/curated/skills/broken-skill/SKILL.md",
|
||||
"external/curated/agents/scout.md",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, p)); err == nil {
|
||||
t.Errorf("excluded item must NOT be materialized: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
|
||||
if !contains(lock, `"mode": "all"`) {
|
||||
t.Errorf("exclude keeps all mode:\n%s", lock)
|
||||
}
|
||||
if !contains(lock, `"exclude": [`) {
|
||||
t.Errorf("exclude list must be recorded:\n%s", lock)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVendorAddExcludeTypoFailsClean pins the typo protection for --exclude.
|
||||
func TestVendorAddExcludeTypoFailsClean(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
standardFixture(h, t)
|
||||
|
||||
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir, "--exclude", "good-skill,nope")
|
||||
if err == nil {
|
||||
t.Fatalf("typo in exclude list must fail:\n%s", out)
|
||||
}
|
||||
if !contains(out, "exclude entry") && !contains(out, "not found upstream") {
|
||||
t.Errorf("error must name the unknown exclude entry:\n%s", out)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(h.workDir, "external/curated")); statErr == nil {
|
||||
t.Error("failed add must not leave an external area behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVendorAddIncludeAndExcludeConflict pins the mutual exclusion.
|
||||
func TestVendorAddIncludeAndExcludeConflict(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
standardFixture(h, t)
|
||||
|
||||
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir,
|
||||
"--include", "good-skill", "--exclude", "scout")
|
||||
if err == nil {
|
||||
t.Fatalf("--include and --exclude together must fail:\n%s", out)
|
||||
}
|
||||
if !contains(out, "--include and --exclude cannot be combined") {
|
||||
t.Errorf("error must explain the conflict:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVendorAddTypesAllowList pins --types: only the listed content types
|
||||
// enter the work repository, recorded as the canonical exclude_types
|
||||
// complement.
|
||||
func TestVendorAddTypesAllowList(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
standardFixture(h, t)
|
||||
|
||||
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills,commands")
|
||||
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil {
|
||||
t.Error("allowed skill must be materialized")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil {
|
||||
t.Error("allowed command must be materialized")
|
||||
}
|
||||
for _, p := range []string{
|
||||
"external/curated/agents/scout.md",
|
||||
"external/curated/mcp/search.yaml",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, p)); err == nil {
|
||||
t.Errorf("item of a non-allowed type must NOT be materialized: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
|
||||
if !contains(lock, `"exclude_types": [`) {
|
||||
t.Errorf("type filter must be recorded as exclude_types:\n%s", lock)
|
||||
}
|
||||
if !contains(lock, `"agents"`) || !contains(lock, `"mcp"`) {
|
||||
t.Errorf("exclude_types must hold the complement (agents, mcp):\n%s", lock)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVendorAddExcludeTypesDenyList pins --exclude-types and its unknown-type
|
||||
// validation.
|
||||
func TestVendorAddExcludeTypesDenyList(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
standardFixture(h, t)
|
||||
|
||||
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--exclude-types", "agents,mcp")
|
||||
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil {
|
||||
t.Error("skill must be materialized")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/scout.md")); err == nil {
|
||||
t.Error("agent of an excluded type must NOT be materialized")
|
||||
}
|
||||
|
||||
out, err := h.run(t, "vendor", "add", "other", h.upstreamDir, "--exclude-types", "skillz")
|
||||
if err == nil {
|
||||
t.Fatalf("unknown type must fail:\n%s", out)
|
||||
}
|
||||
if !contains(out, "unknown content type") || !contains(out, "skills, commands, agents, mcp") {
|
||||
t.Errorf("error must list the valid type names:\n%s", out)
|
||||
}
|
||||
|
||||
out, err = h.run(t, "vendor", "add", "other", h.upstreamDir, "--types", "skills", "--exclude-types", "mcp")
|
||||
if err == nil {
|
||||
t.Fatalf("--types and --exclude-types together must fail:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVendorTypesSurviveUpdate pins the whole point of exclude_types: items
|
||||
// of an excluded type that appear upstream later must not slip back in on
|
||||
// vendor update.
|
||||
func TestVendorTypesSurviveUpdate(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
standardFixture(h, t)
|
||||
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills")
|
||||
|
||||
// upstream gains a new agent and a new skill
|
||||
h.write(t, "agents/latecomer.md", "---\nname: Latecomer\n---\nbody\n")
|
||||
h.write(t, "skills/new-skill/SKILL.md", "---\nname: New Skill\ndescription: fresh\n---\n# New\n")
|
||||
h.commitUpstream(t, "add latecomer agent and new skill")
|
||||
|
||||
h.mustRun(t, "vendor", "update", "curated")
|
||||
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/new-skill/SKILL.md")); err != nil {
|
||||
t.Error("new skill of an allowed type must be vendored by update")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/latecomer.md")); err == nil {
|
||||
t.Error("new agent of an excluded type must NOT re-enter on update")
|
||||
}
|
||||
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
|
||||
if !contains(lock, `"exclude_types": [`) {
|
||||
t.Errorf("type filter must survive the update:\n%s", lock)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVendorSelectTypeFilter pins vendor select parity: --exclude-types /
|
||||
// --types replace the filter, absent flags keep it, --mode all clears it.
|
||||
func TestVendorSelectTypeFilter(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
standardFixture(h, t)
|
||||
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills")
|
||||
|
||||
// replacing the filter via --types
|
||||
h.mustRun(t, "vendor", "select", "curated", "--types", "commands")
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err == nil {
|
||||
t.Error("skill must be dropped after narrowing the type filter")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil {
|
||||
t.Error("command must be materialized after widening the type filter")
|
||||
}
|
||||
|
||||
// an id-only change keeps the type filter
|
||||
h.mustRun(t, "vendor", "select", "curated", "--remove", "review")
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err == nil {
|
||||
t.Error("excluded command must be gone")
|
||||
}
|
||||
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
|
||||
if !contains(lock, `"exclude_types": [`) || !contains(lock, `"skills"`) {
|
||||
t.Errorf("type filter must survive an id-only select:\n%s", lock)
|
||||
}
|
||||
|
||||
// --mode all clears everything, including the type filter
|
||||
h.mustRun(t, "vendor", "select", "curated", "--mode", "all")
|
||||
for _, p := range []string{
|
||||
"external/curated/skills/good-skill/SKILL.md",
|
||||
"external/curated/commands/review.md",
|
||||
"external/curated/agents/scout.md",
|
||||
"external/curated/mcp/search.yaml",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil {
|
||||
t.Errorf("--mode all must re-materialize everything: %s", p)
|
||||
}
|
||||
}
|
||||
lock = readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
|
||||
if contains(lock, `"exclude_types": [`) {
|
||||
t.Errorf("--mode all must clear the type filter:\n%s", lock)
|
||||
}
|
||||
|
||||
// --mode all combined with type flags stays an error
|
||||
out, err := h.run(t, "vendor", "select", "curated", "--mode", "all", "--types", "skills")
|
||||
if err == nil {
|
||||
t.Fatalf("--mode all with --types must fail:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVendorAddIncludeVsTypeFilter pins the explicit conflict error when an
|
||||
// include id belongs to a type the filter excludes.
|
||||
func TestVendorAddIncludeVsTypeFilter(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
standardFixture(h, t)
|
||||
|
||||
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir,
|
||||
"--include", "scout", "--exclude-types", "agents")
|
||||
if err == nil {
|
||||
t.Fatalf("include id of an excluded type must fail:\n%s", out)
|
||||
}
|
||||
if !contains(out, "type filter excludes") {
|
||||
t.Errorf("error must explain the conflict:\n%s", out)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user