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:
2026-08-22 21:52:30 +02:00
parent 2a6498dae1
commit 2c7200380b
16 changed files with 1648 additions and 2 deletions
+148
View File
@@ -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
}
+129
View File
@@ -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")
}
}