From 484a15797c9103aaa3e3b9d538523ae3aeca6735 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sat, 22 Aug 2026 21:54:32 +0200 Subject: [PATCH] feat: validate + vendor list/inspect with --json - validate: offline hard rules (all+include, exclude-without-all, orphaned include entries vs pinned inventory) and lockfile/external-area divergence - vendor list: name/url/ref/rev/mode/item counts, human + json - vendor inspect: per-type inventory with frontmatter metadata and recorded warnings read from the external area, human + json - exit code 1 on invalid state for headless drift checks --- .beads/interactions.jsonl | 1 + internal/cli/query.go | 157 ++++++++++++++++++++++ internal/cli/root.go | 1 + internal/cli/vendor.go | 2 + internal/e2e/query_test.go | 234 +++++++++++++++++++++++++++++++++ internal/vendor/query.go | 259 +++++++++++++++++++++++++++++++++++++ 6 files changed, 654 insertions(+) create mode 100644 internal/cli/query.go create mode 100644 internal/e2e/query_test.go create mode 100644 internal/vendor/query.go diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 980d585..5f6cb6c 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -12,3 +12,4 @@ {"id":"int-b1779011c451e8aecfac8a15e2c332b8","kind":"field_change","created_at":"2026-06-13T07:55:57.404556723Z","actor":"m3ta-chiron","issue_id":"agent-lib-j6b","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Architecture review report written and opened: /tmp/architecture-review-20260613-075521.html"}} {"id":"int-eaab83169be77c1e6a853e502b70362f","kind":"field_change","created_at":"2026-06-13T08:17:27.20786458Z","actor":"m3ta-chiron","issue_id":"agent-lib-feu","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Babysitter Deployment Plan exploration completed. Artifact: .a5c/deployment-plan-exploration.md. Run: 01KV00D3F73F6KY5Q8W4TFER5X"}} {"id":"int-4d5bf5dc890a6963a0e1110a8fb71635","kind":"field_change","created_at":"2026-08-22T19:43:28.592284059Z","actor":"m3ta-chiron","issue_id":"agent-lib-j2w.1","extra":{"field":"status","new_value":"closed","old_value":"open"}} +{"id":"int-090714c2a2d985183cc965b11b5c53d8","kind":"field_change","created_at":"2026-08-22T19:52:30.546382863Z","actor":"m3ta-chiron","issue_id":"agent-lib-j2w.10","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} diff --git a/internal/cli/query.go b/internal/cli/query.go new file mode 100644 index 0000000..abb3b03 --- /dev/null +++ b/internal/cli/query.go @@ -0,0 +1,157 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/m3tam3re/agent-lib/internal/lockfile" + "github.com/m3tam3re/agent-lib/internal/vendor" +) + +func loadLockfileOrFail(cmd *cobra.Command) *lockfile.Lockfile { + lf, err := lockfile.Load(filepath.Join(".", lockfile.FileName)) + if err != nil { + if err == lockfile.ErrNotExist { + fmt.Fprintln(os.Stderr, "error: no lockfile found — run `agent-lib vendor add` first") + } else { + fmt.Fprintln(os.Stderr, "error:", err) + } + os.Exit(1) + } + return lf +} + +func emitJSON(cmd *cobra.Command, v any) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func newValidateCmd() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "validate", + Short: "Check lockfile rules and external-area consistency offline", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + lf := loadLockfileOrFail(cmd) + result := vendor.Validate(".", lf) + if asJSON { + return emitJSON(cmd, result) + } + w := cmd.OutOrStdout() + if result.Valid { + fmt.Fprintf(w, "lockfile valid (%d sources)\n", len(lf.Sources)) + return nil + } + for _, e := range result.Errors { + fmt.Fprintf(w, "%s: %s\n", e.Rule, e.Message) + } + cmd.SilenceErrors = true + os.Exit(1) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output") + return cmd +} + +func newVendorListCmd() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "list", + Short: "List all vendored sources with pin, mode and item counts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + lf := loadLockfileOrFail(cmd) + rows := vendor.List(lf) + if asJSON { + return emitJSON(cmd, map[string]any{"sources": rows}) + } + w := cmd.OutOrStdout() + if len(rows) == 0 { + fmt.Fprintln(w, "no sources vendored yet") + return nil + } + fmt.Fprintf(w, "%-16s %-6s %-9s %-8s %s\n", "SOURCE", "REF", "REV", "MODE", "URL") + for _, r := range rows { + rev := r.Rev + if len(rev) > 8 { + rev = rev[:8] + } + fmt.Fprintf(w, "%-16s %-6s %-9s %-8s %s\n", r.Name, r.Ref, rev, r.Mode, r.URL) + fmt.Fprintf(w, "%16s %s\n", "", countsLine(r.Counts)) + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output") + return cmd +} + +func countsLine(counts map[string]int) string { + line := "" + total := 0 + for _, t := range lockfile.Types { + if n, ok := counts[t]; ok { + if line != "" { + line += ", " + } + line += fmt.Sprintf("%d %s", n, t) + total += n + } + } + return fmt.Sprintf("%d items: %s", total, line) +} + +func newVendorInspectCmd() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "inspect ", + Short: "Show one source's full item inventory with metadata and warnings", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + lf := loadLockfileOrFail(cmd) + report, err := vendor.Inspect(".", args[0], lf) + if err != nil { + return err + } + if asJSON { + return emitJSON(cmd, report) + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "source %s\n", report.Name) + fmt.Fprintf(w, " url: %s\n", report.URL) + fmt.Fprintf(w, " ref: %s\n", report.Ref) + fmt.Fprintf(w, " rev: %s\n", report.Rev) + fmt.Fprintf(w, " mode: %s\n", report.Mode) + for _, t := range lockfile.Types { + items := report.Items[t] + if len(items) == 0 { + continue + } + fmt.Fprintf(w, " %s:\n", t) + for _, it := range items { + fmt.Fprintf(w, " %s", it.DeployedID) + if it.DeployedID != it.UpstreamID { + fmt.Fprintf(w, " (upstream: %s)", it.UpstreamID) + } + if it.Name != "" { + fmt.Fprintf(w, " name=%s", it.Name) + } + fmt.Fprintln(w) + for _, warn := range it.Warnings { + fmt.Fprintf(w, " warning: %s\n", warn) + } + } + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output") + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 99662a6..c4c4084 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -22,6 +22,7 @@ func NewRootCmd() *cobra.Command { } root.AddCommand(newVersionCmd()) root.AddCommand(newVendorCmd()) + root.AddCommand(newValidateCmd()) return root } diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index d0f39a7..3b2fa7b 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -15,6 +15,8 @@ func newVendorCmd() *cobra.Command { Short: "Curate external sources into this work repository", } cmd.AddCommand(newVendorAddCmd()) + cmd.AddCommand(newVendorListCmd()) + cmd.AddCommand(newVendorInspectCmd()) return cmd } diff --git a/internal/e2e/query_test.go b/internal/e2e/query_test.go new file mode 100644 index 0000000..2de76f7 --- /dev/null +++ b/internal/e2e/query_test.go @@ -0,0 +1,234 @@ +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/m3tam3re/agent-lib/internal/lockfile" +) + +func healthySource(t *testing.T, h *harness) { + t.Helper() + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) +} + +func mutateLockfile(t *testing.T, h *harness, fn func(*lockfile.Lockfile)) { + t.Helper() + path := filepath.Join(h.workDir, lockfile.FileName) + lf, err := lockfile.Load(path) + if err != nil { + t.Fatal(err) + } + fn(lf) + if err := lf.Save(path); err != nil { + t.Fatal(err) + } +} + +func TestValidateHealthy(t *testing.T) { + h := newHarness(t) + healthySource(t, h) + + out := h.mustRun(t, "validate") + if !contains(out, "valid") { + t.Errorf("healthy state must validate:\n%s", out) + } + + jsonOut := h.mustRun(t, "validate", "--json") + var result struct { + Valid bool `json:"valid"` + Errors []struct { + Source string `json:"source"` + Rule string `json:"rule"` + } `json:"errors"` + } + if err := json.Unmarshal([]byte(jsonOut), &result); err != nil { + t.Fatalf("validate --json not parseable: %v\n%s", err, jsonOut) + } + if !result.Valid || len(result.Errors) != 0 { + t.Errorf("healthy state must be valid in json: %+v", result) + } +} + +func TestValidateAllWithInclude(t *testing.T) { + h := newHarness(t) + healthySource(t, h) + mutateLockfile(t, h, func(lf *lockfile.Lockfile) { + lf.Sources["superpowers"].Selection.Include = []string{"good-skill"} + }) + + out, err := h.run(t, "validate") + if err == nil { + t.Fatalf("all+include must fail:\n%s", out) + } + if !contains(out, "superpowers") || !contains(out, "include list is set") { + t.Errorf("error must name source and rule:\n%s", out) + } +} + +func TestValidateExcludeWithoutAll(t *testing.T) { + h := newHarness(t) + healthySource(t, h) + mutateLockfile(t, h, func(lf *lockfile.Lockfile) { + lf.Sources["superpowers"].Selection.Mode = lockfile.ModeInclude + lf.Sources["superpowers"].Selection.Include = []string{"good-skill"} + lf.Sources["superpowers"].Selection.Exclude = []string{"scout"} + }) + + out, err := h.run(t, "validate") + if err == nil { + t.Fatalf("exclude without all must fail:\n%s", out) + } + if !contains(out, "exclude list is only valid in all mode") { + t.Errorf("error must name the rule:\n%s", out) + } +} + +func TestValidateOrphanedInclude(t *testing.T) { + h := newHarness(t) + healthySource(t, h) + mutateLockfile(t, h, func(lf *lockfile.Lockfile) { + lf.Sources["superpowers"].Selection.Mode = lockfile.ModeInclude + lf.Sources["superpowers"].Selection.Include = []string{"good-skill", "vanished-item"} + }) + + out, err := h.run(t, "validate") + if err == nil { + t.Fatalf("orphaned include must fail:\n%s", out) + } + if !contains(out, "vanished-item") || !contains(out, "pinned inventory") { + t.Errorf("error must name source and item:\n%s", out) + } + + jsonOut, _ := h.run(t, "validate", "--json") + var result struct { + Valid bool `json:"valid"` + Errors []struct { + Source string `json:"source"` + } `json:"errors"` + } + if err := json.Unmarshal([]byte(jsonOut), &result); err != nil { + t.Fatalf("json output not parseable: %v", err) + } + if result.Valid || len(result.Errors) == 0 || result.Errors[0].Source != "superpowers" { + t.Errorf("json must carry the error with source: %+v", result) + } +} + +func TestValidateTreeLockfileDivergence(t *testing.T) { + h := newHarness(t) + healthySource(t, h) + + os.Remove(filepath.Join(h.workDir, "external/superpowers/agents/scout.md")) + out, err := h.run(t, "validate") + if err == nil { + t.Fatalf("missing tree item must fail:\n%s", out) + } + if !contains(out, "agents") || !contains(out, "scout") { + t.Errorf("error must name type and item:\n%s", out) + } + + stray := filepath.Join(h.workDir, "external/superpowers/commands/ghost.md") + os.WriteFile(stray, []byte("# ghost\n"), 0o644) + out, err = h.run(t, "validate") + if err == nil { + t.Fatalf("stray tree item must fail:\n%s", out) + } + if !contains(out, "ghost") { + t.Errorf("error must name the stray item:\n%s", out) + } +} + +func TestVendorList(t *testing.T) { + h := newHarness(t) + healthySource(t, h) + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill") + + out := h.mustRun(t, "vendor", "list") + for _, want := range []string{"superpowers", "curated", "all", "include", "main"} { + if !contains(out, want) { + t.Errorf("list output missing %q:\n%s", want, out) + } + } + + jsonOut := h.mustRun(t, "vendor", "list", "--json") + var parsed struct { + Sources []struct { + Name string `json:"name"` + URL string `json:"url"` + Ref string `json:"ref"` + Rev string `json:"rev"` + Mode string `json:"mode"` + Counts map[string]int `json:"counts"` + Total int `json:"total"` + } `json:"sources"` + } + if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { + t.Fatalf("list --json not parseable: %v\n%s", err, jsonOut) + } + if len(parsed.Sources) != 2 { + t.Fatalf("want 2 sources, got %d", len(parsed.Sources)) + } + super := parsed.Sources[1] + if super.Name != "superpowers" || super.Mode != "all" || super.Total != 5 { + t.Errorf("superpowers summary wrong: %+v", super) + } + if super.Counts["skills"] != 2 || super.Counts["mcp"] != 1 { + t.Errorf("counts wrong: %+v", super.Counts) + } +} + +func TestVendorInspect(t *testing.T) { + h := newHarness(t) + healthySource(t, h) + + out := h.mustRun(t, "vendor", "inspect", "superpowers") + for _, want := range []string{"good-skill", "broken-skill", "scout", "search", "name=Good Skill", "warning"} { + if !contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } + + jsonOut := h.mustRun(t, "vendor", "inspect", "superpowers", "--json") + var report struct { + Name string `json:"name"` + Ref string `json:"ref"` + Items map[string][]struct { + UpstreamID string `json:"upstream_id"` + DeployedID string `json:"deployed_id"` + Name string `json:"name"` + Description string `json:"description"` + Warnings []string `json:"warnings"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(jsonOut), &report); err != nil { + t.Fatalf("inspect --json not parseable: %v\n%s", err, jsonOut) + } + if report.Name != "superpowers" || len(report.Items["skills"]) != 2 { + t.Errorf("inspect json wrong: %+v", report) + } + for _, it := range report.Items["skills"] { + if it.UpstreamID == "good-skill" && it.Name != "Good Skill" { + t.Errorf("frontmatter metadata missing: %+v", it) + } + if it.UpstreamID == "broken-skill" && len(it.Warnings) == 0 { + t.Errorf("recorded warnings missing: %+v", it) + } + } +} + +func TestVendorInspectUnknownSource(t *testing.T) { + h := newHarness(t) + healthySource(t, h) + + out, err := h.run(t, "vendor", "inspect", "nope") + if err == nil { + t.Fatalf("unknown source must fail:\n%s", out) + } + if !contains(out, "not in the lockfile") { + t.Errorf("error must be explicit:\n%s", out) + } +} diff --git a/internal/vendor/query.go b/internal/vendor/query.go new file mode 100644 index 0000000..7b4e813 --- /dev/null +++ b/internal/vendor/query.go @@ -0,0 +1,259 @@ +package vendor + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/m3tam3re/agent-lib/internal/discovery" + "github.com/m3tam3re/agent-lib/internal/lockfile" +) + +// ValidationError names one broken rule. +type ValidationError struct { + Source string `json:"source,omitempty"` + Rule string `json:"rule"` + Message string `json:"message"` +} + +// Validation is the full offline verdict over a lockfile and its external area. +type Validation struct { + Valid bool `json:"valid"` + Errors []ValidationError `json:"errors"` +} + +// Validate checks the hard selection rules and the lockfile/external-area +// consistency, entirely offline. +func Validate(workDir string, lf *lockfile.Lockfile) Validation { + v := Validation{Valid: true} + for _, name := range sortedSourceNames(lf) { + src := lf.Sources[name] + v.checkSelection(name, src) + v.checkExternalArea(workDir, name, src) + } + sort.Slice(v.Errors, func(i, j int) bool { + if v.Errors[i].Source != v.Errors[j].Source { + return v.Errors[i].Source < v.Errors[j].Source + } + return v.Errors[i].Rule < v.Errors[j].Rule + }) + v.Valid = len(v.Errors) == 0 + return v +} + +func (v *Validation) fail(source, rule, format string, args ...any) { + v.Errors = append(v.Errors, ValidationError{ + Source: source, + Rule: rule, + Message: fmt.Sprintf(format, args...), + }) +} + +func (v *Validation) checkSelection(name string, src *lockfile.Source) { + switch src.Selection.Mode { + case lockfile.ModeAll: + if len(src.Selection.Include) > 0 { + v.fail(name, "selection", "source %q: include list is set but mode is all (remove the include list or switch to include mode)", name) + } + case lockfile.ModeInclude: + if len(src.Selection.Exclude) > 0 { + v.fail(name, "selection", "source %q: exclude list is only valid in all mode", name) + } + inventory := map[string]bool{} + for _, items := range src.Inventory { + for _, id := range items { + inventory[id] = true + } + } + for _, inc := range src.Selection.Include { + if !inventory[inc] { + v.fail(name, "selection", "source %q: include entry %q is not in the pinned inventory", name, inc) + } + } + default: + v.fail(name, "selection", "source %q: unknown selection mode %q (want %q or %q)", name, src.Selection.Mode, lockfile.ModeAll, lockfile.ModeInclude) + } +} + +func (v *Validation) checkExternalArea(workDir, name string, src *lockfile.Source) { + base := filepath.Join(workDir, externalDir, name) + if _, err := os.Stat(base); err != nil { + v.fail(name, "tree", "source %q: external area %s is missing", name, base) + return + } + for _, typ := range lockfile.Types { + want := map[string]bool{} + for _, upstreamID := range src.Inventory[typ] { + want[deployedName(upstreamID, src.Renames)] = true + } + got, err := scanDeployedNames(filepath.Join(base, typ), typ) + if err != nil { + v.fail(name, "tree", "source %q: reading %s area: %v", name, typ, err) + continue + } + for _, dep := range sortedSet(want) { + if !got[dep] { + v.fail(name, "tree", "source %q: %s item %q is pinned in the lockfile but missing from the external area", name, typ, dep) + } + } + for _, dep := range sortedSet(got) { + if !want[dep] { + v.fail(name, "tree", "source %q: %s item %q exists in the external area but is not in the lockfile", name, typ, dep) + } + } + } +} + +func scanDeployedNames(dir, typ string) (map[string]bool, error) { + out := map[string]bool{} + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + for _, e := range entries { + switch typ { + case lockfile.TypeSkill: + if e.IsDir() { + out[e.Name()] = true + } + case lockfile.TypeCommand, lockfile.TypeAgent: + if strings.HasSuffix(e.Name(), ".md") { + out[strings.TrimSuffix(e.Name(), ".md")] = true + } + case lockfile.TypeMcp: + name := e.Name() + if strings.HasSuffix(name, ".yaml") { + out[strings.TrimSuffix(name, ".yaml")] = true + } else if strings.HasSuffix(name, ".yml") { + out[strings.TrimSuffix(name, ".yml")] = true + } + } + } + return out, nil +} + +func sortedSourceNames(lf *lockfile.Lockfile) []string { + out := make([]string, 0, len(lf.Sources)) + for name := range lf.Sources { + out = append(out, name) + } + sort.Strings(out) + return out +} + +func sortedSet(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// ItemInfo describes one deployed item for inspect. +type ItemInfo struct { + UpstreamID string `json:"upstream_id"` + DeployedID string `json:"deployed_id"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// SourceSummary is one row of vendor list. +type SourceSummary struct { + Name string `json:"name"` + URL string `json:"url"` + Ref string `json:"ref"` + Rev string `json:"rev"` + Mode string `json:"mode"` + Counts map[string]int `json:"counts"` + Total int `json:"total"` +} + +// List summarizes every source from the lockfile. +func List(lf *lockfile.Lockfile) []SourceSummary { + var out []SourceSummary + for _, name := range sortedSourceNames(lf) { + src := lf.Sources[name] + s := SourceSummary{Name: name, URL: src.URL, Ref: src.Ref, Rev: src.Rev, Mode: string(src.Selection.Mode), Counts: map[string]int{}} + for _, typ := range lockfile.Types { + n := len(src.Inventory[typ]) + if n > 0 { + s.Counts[typ] = n + s.Total += n + } + } + out = append(out, s) + } + return out +} + +// InspectReport is the full per-source view with metadata from the external area. +type InspectReport struct { + Name string `json:"name"` + URL string `json:"url"` + Ref string `json:"ref"` + Rev string `json:"rev"` + Mode string `json:"mode"` + Items map[string][]ItemInfo `json:"items"` + Warnings []string `json:"warnings,omitempty"` +} + +// Inspect reads the external area of one source and enriches the pinned +// inventory with frontmatter metadata from the deployed files. +func Inspect(workDir, name string, lf *lockfile.Lockfile) (*InspectReport, error) { + src, ok := lf.Sources[name] + if !ok { + return nil, fmt.Errorf("source %q is not in the lockfile", name) + } + base := filepath.Join(workDir, externalDir, name) + tree := &discovery.FsTree{Root: base} + warningsByPrefix := map[string][]string{} + for _, w := range src.Warnings { + prefix, rest, found := strings.Cut(w, ": ") + if found { + warningsByPrefix[prefix] = append(warningsByPrefix[prefix], rest) + } + } + report := &InspectReport{ + Name: name, + URL: src.URL, + Ref: src.Ref, + Rev: src.Rev, + Mode: string(src.Selection.Mode), + Items: map[string][]ItemInfo{}, + Warnings: src.Warnings, + } + for _, typ := range lockfile.Types { + for _, upstreamID := range src.Inventory[typ] { + dep := deployedName(upstreamID, src.Renames) + info := ItemInfo{UpstreamID: upstreamID, DeployedID: dep, Warnings: warningsByPrefix[typ+"/"+upstreamID]} + switch typ { + case lockfile.TypeSkill: + if data, err := tree.ReadFile(typ + "/" + dep + "/SKILL.md"); err == nil { + fillFrontmatter(&info, data) + } + case lockfile.TypeMcp: + default: + if data, err := tree.ReadFile(typ + "/" + dep + ".md"); err == nil { + fillFrontmatter(&info, data) + } + } + report.Items[typ] = append(report.Items[typ], info) + } + } + return report, nil +} + +func fillFrontmatter(info *ItemInfo, data []byte) { + fm, _ := discovery.ParseFrontmatter(data) + info.Name = fm.Name + info.Description = fm.Description + info.Tags = fm.Tags +}