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
This commit is contained in:
@@ -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"}}
|
||||
|
||||
@@ -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 <name>",
|
||||
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
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func NewRootCmd() *cobra.Command {
|
||||
}
|
||||
root.AddCommand(newVersionCmd())
|
||||
root.AddCommand(newVendorCmd())
|
||||
root.AddCommand(newValidateCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+259
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user