2026-08-22 21:54:32 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-22 21:56:21 +02:00
|
|
|
v.Errors = append(v.Errors, validateCollisions(workDir, lf)...)
|
2026-08-22 21:54:32 +02:00
|
|
|
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
|
|
|
|
|
}
|