feat: vendor add end-to-end with deterministic lockfile v2
- go-git in-process clone, default-branch/tag resolution, optional token auth - web-tree URL normalization (github/gitlab) with implied discovery root - discovery of skills/commands/agents/mcp, lenient frontmatter with warnings - selection: all by default, --include with missing-entry hard errors - external/<source>/<type>/ materialization preserving exec bits - lockfile v2: deterministic JSON, pinned url/ref/rev, inventory, warnings - offline black-box e2e suite against local fixture git repositories
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// Package lockfile defines agent-lib lockfile schema v2: the deterministic
|
||||
// JSON document pinning vendored sources, their selection and renames.
|
||||
package lockfile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
// Version is the schema version of lockfile v2.
|
||||
Version = 2
|
||||
// FileName is the lockfile name inside the work repository root.
|
||||
FileName = "agent-lib.lock.json"
|
||||
)
|
||||
|
||||
// ErrNotExist reports that no lockfile exists yet.
|
||||
var ErrNotExist = errors.New("no lockfile found")
|
||||
|
||||
// Content type names used across discovery, lockfile and the external area.
|
||||
const (
|
||||
TypeSkill = "skills"
|
||||
TypeCommand = "commands"
|
||||
TypeAgent = "agents"
|
||||
TypeMcp = "mcp"
|
||||
)
|
||||
|
||||
// Types lists all content types in canonical order.
|
||||
var Types = []string{TypeSkill, TypeCommand, TypeAgent, TypeMcp}
|
||||
|
||||
// Discovery configures where items are found inside a source repository.
|
||||
// Empty values select the standard layouts (skills/, commands/, agents/, mcp/
|
||||
// relative to Root).
|
||||
type Discovery struct {
|
||||
Root string `json:"root,omitempty"`
|
||||
SkillsDir string `json:"skills_dir,omitempty"`
|
||||
CommandsDir string `json:"commands_dir,omitempty"`
|
||||
AgentsDir string `json:"agents_dir,omitempty"`
|
||||
McpDir string `json:"mcp_dir,omitempty"`
|
||||
}
|
||||
|
||||
func (d Discovery) Resolve() Discovery {
|
||||
r := d
|
||||
if r.SkillsDir == "" {
|
||||
r.SkillsDir = TypeSkill
|
||||
}
|
||||
if r.CommandsDir == "" {
|
||||
r.CommandsDir = TypeCommand
|
||||
}
|
||||
if r.AgentsDir == "" {
|
||||
r.AgentsDir = TypeAgent
|
||||
}
|
||||
if r.McpDir == "" {
|
||||
r.McpDir = TypeMcp
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// SelectionMode is either "all" or "include".
|
||||
type SelectionMode string
|
||||
|
||||
const (
|
||||
ModeAll SelectionMode = "all"
|
||||
ModeInclude SelectionMode = "include"
|
||||
)
|
||||
|
||||
// Selection expresses what enters the work repository: everything minus
|
||||
// Exclude (mode all), or exactly Include (mode include).
|
||||
type Selection struct {
|
||||
Mode SelectionMode `json:"mode"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
}
|
||||
|
||||
// Source pins one vendored git source.
|
||||
type Source struct {
|
||||
URL string `json:"url"`
|
||||
Ref string `json:"ref"`
|
||||
Rev string `json:"rev"`
|
||||
Discovery *Discovery `json:"discovery,omitempty"`
|
||||
Selection Selection `json:"selection"`
|
||||
Renames map[string]string `json:"renames,omitempty"`
|
||||
Inventory map[string][]string `json:"inventory,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// Lockfile is the root document of schema v2.
|
||||
type Lockfile struct {
|
||||
Version int `json:"version"`
|
||||
Sources map[string]*Source `json:"sources"`
|
||||
}
|
||||
|
||||
// New returns an empty lockfile v2.
|
||||
func New() *Lockfile {
|
||||
return &Lockfile{Version: Version, Sources: map[string]*Source{}}
|
||||
}
|
||||
|
||||
// Load reads the lockfile at path. A missing file yields ErrNotExist.
|
||||
func Load(path string) (*Lockfile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var l Lockfile
|
||||
if err := json.Unmarshal(data, &l); err != nil {
|
||||
return nil, fmt.Errorf("lockfile %s is not valid JSON: %w", path, err)
|
||||
}
|
||||
if l.Version != Version {
|
||||
return nil, fmt.Errorf("lockfile %s has schema version %d, want %d", path, l.Version, Version)
|
||||
}
|
||||
if l.Sources == nil {
|
||||
l.Sources = map[string]*Source{}
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Save canonically orders all slices and maps, then writes deterministic JSON.
|
||||
func (l *Lockfile) Save(path string) error {
|
||||
for _, src := range l.Sources {
|
||||
sort.Strings(src.Selection.Exclude)
|
||||
sort.Strings(src.Selection.Include)
|
||||
sort.Strings(src.Warnings)
|
||||
for _, items := range src.Inventory {
|
||||
sort.Strings(items)
|
||||
}
|
||||
}
|
||||
data, err := Marshal(l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
// Marshal renders the lockfile as deterministic bytes: fixed field order via
|
||||
// struct layout, sorted map keys, two-space indent, trailing newline.
|
||||
func Marshal(l *Lockfile) ([]byte, error) {
|
||||
data, err := json.MarshalIndent(l, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package lockfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestMarshalDeterministic(t *testing.T) {
|
||||
l1 := New()
|
||||
l1.Sources["beta"] = &Source{
|
||||
URL: "https://example.com/beta.git", Ref: "main", Rev: "cafe123",
|
||||
Selection: Selection{Mode: ModeAll},
|
||||
Renames: map[string]string{"zeta": "beta-zeta"},
|
||||
Inventory: map[string][]string{TypeSkill: {"a", "zeta"}},
|
||||
Warnings: []string{"a-warning", "b-warning"},
|
||||
}
|
||||
l1.Sources["alpha"] = &Source{
|
||||
URL: "https://example.com/alpha.git", Ref: "v1.0", Rev: "abcd00",
|
||||
Selection: Selection{Mode: ModeInclude, Include: []string{"a", "x"}},
|
||||
}
|
||||
|
||||
l2 := New()
|
||||
l2.Sources["alpha"] = l1.Sources["alpha"]
|
||||
l2.Sources["beta"] = l1.Sources["beta"]
|
||||
|
||||
b1, err := Marshal(l1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b2, err := Marshal(l2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b1) != string(b2) {
|
||||
t.Fatalf("marshal not deterministic:\n%s\n--- vs ---\n%s", b1, b2)
|
||||
}
|
||||
if b1[len(b1)-1] != '\n' {
|
||||
t.Error("missing trailing newline")
|
||||
}
|
||||
|
||||
l1.Save(filepath.Join(t.TempDir(), FileName))
|
||||
b3, err := Marshal(l1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b1) != string(b3) {
|
||||
t.Fatalf("save mutated already-canonical bytes:\n%s\n--- vs ---\n%s", b1, b3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCanonicalizesUnsortedInput(t *testing.T) {
|
||||
l := New()
|
||||
l.Sources["s"] = &Source{
|
||||
URL: "u", Ref: "r", Rev: "rev",
|
||||
Selection: Selection{Mode: ModeInclude, Include: []string{"z", "a"}},
|
||||
Warnings: []string{"w2", "w1"},
|
||||
Inventory: map[string][]string{TypeSkill: {"z", "a"}},
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), FileName)
|
||||
if err := l.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := got.Sources["s"]
|
||||
wantInclude := []string{"a", "z"}
|
||||
if !slicesEqual(src.Selection.Include, wantInclude) {
|
||||
t.Errorf("include = %v, want %v", src.Selection.Include, wantInclude)
|
||||
}
|
||||
wantWarnings := []string{"w1", "w2"}
|
||||
if !slicesEqual(src.Warnings, wantWarnings) {
|
||||
t.Errorf("warnings = %v, want %v", src.Warnings, wantWarnings)
|
||||
}
|
||||
wantInv := []string{"a", "z"}
|
||||
if !slicesEqual(src.Inventory[TypeSkill], wantInv) {
|
||||
t.Errorf("inventory = %v, want %v", src.Inventory[TypeSkill], wantInv)
|
||||
}
|
||||
}
|
||||
|
||||
func slicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, FileName)
|
||||
l := New()
|
||||
l.Sources["s"] = &Source{URL: "u", Ref: "r", Rev: "rev1", Selection: Selection{Mode: ModeAll}}
|
||||
if err := l.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Version != Version || got.Sources["s"].Rev != "rev1" {
|
||||
t.Fatalf("round trip lost data: %+v", got)
|
||||
}
|
||||
|
||||
if _, err := Load(filepath.Join(dir, "missing.json")); err != ErrNotExist {
|
||||
t.Fatalf("want ErrNotExist, got %v", err)
|
||||
}
|
||||
|
||||
os.WriteFile(path, []byte(`{"version": 1, "sources": {}}`), 0o644)
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("version 1 lockfile must be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user