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 }