feat(vendor): select command — manage include/exclude selection after initial add
vendor select <name> --add/--remove/--mode all changes what a source contributes without advancing the pinned rev: ids are validated against the pinned upstream (typos abort listing available ids), collisions re-run, renames from the lockfile are honored, and the external area is rewritten via the same staging-swap as update. include mode grows and shrinks the include list; all mode --remove moves ids onto exclude; --mode all resets to everything. Failed selects mutate nothing. Closes beads: agent-lib-cd3
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func buildTwentySkillUpstream(t *testing.T, h *harness) {
|
||||
t.Helper()
|
||||
for i := 1; i <= 20; i++ {
|
||||
h.write(t, fmt.Sprintf("skills/skill-%02d/SKILL.md", i), fmt.Sprintf("---\nname: Skill %02d\ndescription: s%d\n---\n# %02d\n", i, i, i))
|
||||
}
|
||||
h.commitUpstream(t, "twenty skills")
|
||||
}
|
||||
|
||||
type lockView struct {
|
||||
Version int `json:"version"`
|
||||
Sources map[string]struct {
|
||||
Rev string `json:"rev"`
|
||||
Renames map[string]string `json:"renames"`
|
||||
Selection struct {
|
||||
Mode string `json:"mode"`
|
||||
Exclude []string `json:"exclude"`
|
||||
Include []string `json:"include"`
|
||||
} `json:"selection"`
|
||||
Inventory map[string][]string `json:"inventory"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
|
||||
func readLock(t *testing.T, workDir string) lockView {
|
||||
t.Helper()
|
||||
data := readFile(t, filepath.Join(workDir, "agent-lib.lock.json"))
|
||||
var v lockView
|
||||
if err := json.Unmarshal([]byte(data), &v); err != nil {
|
||||
t.Fatalf("lockfile not parseable: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func externalSkillIDs(t *testing.T, workDir, name string) []string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(workDir, "external", name, "skills")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func snapshotTree(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
var paths []string
|
||||
filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err == nil {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Strings(paths)
|
||||
out := ""
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err == nil {
|
||||
out += p + ":" + fmt.Sprint(len(data)) + "\n"
|
||||
} else {
|
||||
out += p + ":dir\n"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addWithInclude(t *testing.T, h *harness, firstFive string) {
|
||||
t.Helper()
|
||||
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir, "--include", firstFive)
|
||||
}
|
||||
|
||||
func TestVendorSelectAddGrowsSelection(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05")
|
||||
revBefore := readLock(t, h.workDir).Sources["superpowers"].Rev
|
||||
|
||||
out := h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-06,skill-07,skill-08")
|
||||
if !contains(out, "skill-06 (added)") || !contains(out, "skill-08 (added)") {
|
||||
t.Errorf("report must list added items:\n%s", out)
|
||||
}
|
||||
|
||||
got := externalSkillIDs(t, h.workDir, "superpowers")
|
||||
if len(got) != 8 {
|
||||
t.Errorf("external area must hold exactly 8 skills, got %d: %v", len(got), got)
|
||||
}
|
||||
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||
if len(v.Selection.Include) != 8 {
|
||||
t.Errorf("include list must hold 8 ids, got %v", v.Selection.Include)
|
||||
}
|
||||
if v.Rev != revBefore {
|
||||
t.Error("select must never advance the pinned rev")
|
||||
}
|
||||
if out := h.mustRun(t, "validate"); contains(out, "invalid") {
|
||||
t.Errorf("validate must stay green:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorSelectRemoveInIncludeMode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05")
|
||||
|
||||
out := h.mustRun(t, "vendor", "select", "superpowers", "--remove", "skill-02,skill-04")
|
||||
if !contains(out, "skill-02 (removed)") || !contains(out, "skill-04 (removed)") {
|
||||
t.Errorf("report must list removed items:\n%s", out)
|
||||
}
|
||||
|
||||
got := externalSkillIDs(t, h.workDir, "superpowers")
|
||||
want := []string{"skill-01", "skill-03", "skill-05"}
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Errorf("external skills = %v, want %v", got, want)
|
||||
}
|
||||
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||
if fmt.Sprint(v.Selection.Include) != fmt.Sprint(want) {
|
||||
t.Errorf("include list = %v, want %v", v.Selection.Include, want)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(h.workDir, "agent-lib.lock.json")
|
||||
before := readFile(t, lockPath)
|
||||
h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-01")
|
||||
if after := readFile(t, lockPath); after != before {
|
||||
t.Error("no-op select must leave the lockfile byte-identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorSelectRemoveInAllMode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
|
||||
|
||||
h.mustRun(t, "vendor", "select", "superpowers", "--remove", "skill-03,skill-17")
|
||||
|
||||
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||
if v.Selection.Mode != "all" {
|
||||
t.Errorf("mode must stay all, got %q", v.Selection.Mode)
|
||||
}
|
||||
if fmt.Sprint(v.Selection.Exclude) != fmt.Sprint([]string{"skill-03", "skill-17"}) {
|
||||
t.Errorf("exclude list = %v", v.Selection.Exclude)
|
||||
}
|
||||
got := externalSkillIDs(t, h.workDir, "superpowers")
|
||||
if len(got) != 18 {
|
||||
t.Errorf("external area must hold 18 skills, got %d", len(got))
|
||||
}
|
||||
for _, gone := range []string{"skill-03", "skill-17"} {
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external", "superpowers", "skills", gone)); !os.IsNotExist(err) {
|
||||
t.Errorf("removed skill %s must be gone from the external area", gone)
|
||||
}
|
||||
}
|
||||
h.mustRun(t, "validate")
|
||||
}
|
||||
|
||||
func TestVendorSelectModeAllResets(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01,skill-02,skill-03")
|
||||
|
||||
out := h.mustRun(t, "vendor", "select", "superpowers", "--mode", "all")
|
||||
for _, id := range []string{"skill-04", "skill-20"} {
|
||||
if !contains(out, id+" (added)") {
|
||||
t.Errorf("report must list what arrived (missing %s):\n%s", id, out)
|
||||
}
|
||||
}
|
||||
|
||||
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||
if v.Selection.Mode != "all" || len(v.Selection.Include) > 0 || len(v.Selection.Exclude) > 0 {
|
||||
t.Errorf("mode all must clear include/exclude: %+v", v.Selection)
|
||||
}
|
||||
if got := externalSkillIDs(t, h.workDir, "superpowers"); len(got) != 20 {
|
||||
t.Errorf("all 20 skills must be materialized, got %d", len(got))
|
||||
}
|
||||
h.mustRun(t, "validate")
|
||||
}
|
||||
|
||||
func TestVendorSelectAddTypoIsHardError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01,skill-02")
|
||||
|
||||
lockPath := filepath.Join(h.workDir, "agent-lib.lock.json")
|
||||
lockBefore := readFile(t, lockPath)
|
||||
treeBefore := snapshotTree(t, filepath.Join(h.workDir, "external"))
|
||||
|
||||
out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-99")
|
||||
if err == nil {
|
||||
t.Fatalf("unknown id must be a hard error:\n%s", out)
|
||||
}
|
||||
if !contains(out, "skill-99") || !contains(out, "skill-01") {
|
||||
t.Errorf("error must name the missing id and list available ids:\n%s", out)
|
||||
}
|
||||
if readFile(t, lockPath) != lockBefore {
|
||||
t.Error("failed select must not mutate the lockfile")
|
||||
}
|
||||
if snapshotTree(t, filepath.Join(h.workDir, "external")) != treeBefore {
|
||||
t.Error("failed select must not mutate the external area")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorSelectRemoveTypoInIncludeMode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01,skill-02")
|
||||
|
||||
out, err := h.run(t, "vendor", "select", "superpowers", "--remove", "skill-09")
|
||||
if err == nil {
|
||||
t.Fatalf("--remove of an unselected id must be a hard error:\n%s", out)
|
||||
}
|
||||
if !contains(out, "not in the current selection") {
|
||||
t.Errorf("error must explain the id is not selected:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorSelectAddInAllModeIsError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
|
||||
|
||||
out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-04")
|
||||
if err == nil {
|
||||
t.Fatalf("--add in all mode must be a hard error:\n%s", out)
|
||||
}
|
||||
if !contains(out, "already selected in all mode") {
|
||||
t.Errorf("error must explain that everything is selected in all mode:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorSelectCollisionGuardAndRenameHonored(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01,skill-02")
|
||||
os.MkdirAll(filepath.Join(h.workDir, "skills", "skill-09"), 0o755)
|
||||
os.WriteFile(filepath.Join(h.workDir, "skills", "skill-09", "SKILL.md"), []byte("---\nname: Own Nine\n---\n"), 0o644)
|
||||
|
||||
out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-09")
|
||||
if err == nil {
|
||||
t.Fatalf("collision must abort the select:\n%s", out)
|
||||
}
|
||||
if !contains(out, "collides with own skills") {
|
||||
t.Errorf("error must name both parties:\n%s", out)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(h.workDir, "agent-lib.lock.json")
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(readFile(t, lockPath)), &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := raw["sources"].(map[string]any)["superpowers"].(map[string]any)
|
||||
src["renames"] = map[string]string{"skill-09": "nine-renamed"}
|
||||
data, _ := json.Marshal(raw)
|
||||
os.WriteFile(lockPath, data, 0o644)
|
||||
|
||||
h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-09")
|
||||
if _, err := os.Stat(filepath.Join(h.workDir, "external", "superpowers", "skills", "nine-renamed", "SKILL.md")); err != nil {
|
||||
t.Error("renamed item must materialize under its deployed name")
|
||||
}
|
||||
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||
if v.Renames["skill-09"] != "nine-renamed" {
|
||||
t.Errorf("rename map must survive select: %v", v.Renames)
|
||||
}
|
||||
if out := h.mustRun(t, "validate"); contains(out, "invalid") {
|
||||
t.Errorf("validate must stay green after rename select:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorSelectModeAllRejectsCombinedFlags(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01")
|
||||
|
||||
out, err := h.run(t, "vendor", "select", "superpowers", "--mode", "all", "--add", "skill-02")
|
||||
if err == nil {
|
||||
t.Fatalf("--mode all with --add must be rejected:\n%s", out)
|
||||
}
|
||||
if !contains(out, "cannot be combined") {
|
||||
t.Errorf("error must explain the flag conflict:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorSelectNoFlagsIsError(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01")
|
||||
|
||||
out, err := h.run(t, "vendor", "select", "superpowers")
|
||||
if err == nil {
|
||||
t.Fatalf("select without flags must be rejected:\n%s", out)
|
||||
}
|
||||
if !contains(out, "nothing to do") {
|
||||
t.Errorf("error must list the available actions:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorSelectUnknownSourceAndUpstreamAdvance(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
buildTwentySkillUpstream(t, h)
|
||||
addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05")
|
||||
revPinned := readLock(t, h.workDir).Sources["superpowers"].Rev
|
||||
|
||||
if out, err := h.run(t, "vendor", "select", "nope"); err == nil {
|
||||
t.Fatalf("unknown source must fail:\n%s", out)
|
||||
} else if !contains(out, "not in the lockfile") {
|
||||
t.Errorf("error must name the lockfile:\n%s", out)
|
||||
}
|
||||
|
||||
h.write(t, "skills/skill-01/SKILL.md", "---\nname: Skill 01\ndescription: changed upstream\n---\n# changed\n")
|
||||
h.commitUpstream(t, "upstream moves on")
|
||||
|
||||
h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-06")
|
||||
|
||||
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||
if v.Rev != revPinned {
|
||||
t.Error("select must keep the pinned rev even when upstream moved on")
|
||||
}
|
||||
changed, err := os.ReadFile(filepath.Join(h.workDir, "external", "superpowers", "skills", "skill-01", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(string(changed), "changed upstream") {
|
||||
t.Error("select must materialize pinned content, not the ref head")
|
||||
}
|
||||
if out := h.mustRun(t, "validate"); contains(out, "invalid") {
|
||||
t.Errorf("validate must stay green:\n%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user