feat: collision hard errors + rename resolution

- flat per-type deployed namespace enforced across own items and all sources
- own-vs-vendored and vendored-vs-vendored collisions abort with both parties named
- --rename upstream=deployed resolves collisions; on-disk folder always equals
  deployed name; rename targets are validated against new collisions
- checks run before any tree mutation; validate re-checks the namespace offline
This commit is contained in:
2026-08-22 21:56:21 +02:00
parent 484a15797c
commit 36ce7dd747
6 changed files with 308 additions and 1 deletions
+8
View File
@@ -23,6 +23,7 @@ type AddOptions struct {
URL string
Ref string
Include []string
Renames map[string]string
}
// Add vendors a new source into the work repository at workDir: it clones,
@@ -78,6 +79,10 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
return fmt.Errorf("source %q selected zero items; refusing to add an empty source", opts.Name)
}
if err := checkCollisions(workDir, lf, opts.Name, selected, opts.Renames); err != nil {
return err
}
src := &lockfile.Source{
URL: normalized,
Ref: repo.Ref,
@@ -85,6 +90,9 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
Selection: sel,
Renames: map[string]string{},
}
if len(opts.Renames) > 0 {
src.Renames = opts.Renames
}
if cfg != (lockfile.Discovery{}) {
src.Discovery = &cfg
}
+168
View File
@@ -0,0 +1,168 @@
package vendor
import (
"fmt"
"sort"
"strings"
"github.com/m3tam3re/agent-lib/internal/discovery"
"github.com/m3tam3re/agent-lib/internal/lockfile"
)
// CollisionError names both parties of a deployed-name clash.
type CollisionError struct {
Type string `json:"type"`
DeployedID string `json:"deployed_id"`
Source string `json:"source"`
OtherOwner string `json:"other_owner"`
OtherID string `json:"other_id"`
}
func (e *CollisionError) Error() string {
return fmt.Sprintf("source %q: %s %q collides with %s %s %q",
e.Source, e.Type, e.DeployedID, e.OtherOwner, e.Type, e.OtherID)
}
// deployedIndex maps type -> deployed name -> owner description, built from
// the work repository's own items and every lockfile source except skip.
type deployedIndex map[string]map[string]owner
type owner struct {
source string
id string
}
func ownDeployed(workDir string) (deployedIndex, error) {
items, err := discovery.Scan(&discovery.FsTree{Root: workDir}, discovery.Config{})
if err != nil {
return nil, err
}
idx := deployedIndex{}
for _, it := range items {
idx.add(it.Type, it.UpstreamID, owner{source: "own", id: it.UpstreamID})
}
return idx, nil
}
func lockfileDeployed(lf *lockfile.Lockfile, skip string) deployedIndex {
idx := deployedIndex{}
for name, src := range lf.Sources {
if name == skip {
continue
}
for typ, ids := range src.Inventory {
for _, id := range ids {
idx.add(typ, deployedName(id, src.Renames), owner{source: name, id: id})
}
}
}
return idx
}
func (d deployedIndex) add(typ, deployed string, o owner) {
if d[typ] == nil {
d[typ] = map[string]owner{}
}
d[typ][deployed] = o
}
func (d deployedIndex) find(typ, deployed string) (owner, bool) {
o, ok := d[typ][deployed]
return o, ok
}
func mergeIndex(a, b deployedIndex) deployedIndex {
out := deployedIndex{}
for typ, m := range a {
for dep, o := range m {
out.add(typ, dep, o)
}
}
for typ, m := range b {
for dep, o := range m {
out.add(typ, dep, o)
}
}
return out
}
// checkCollisions verifies that every selected item of source deploys into
// the flat namespace without clashing — against own items, against other
// sources, and within the source itself (renames included). It performs no
// filesystem mutation.
func checkCollisions(workDir string, lf *lockfile.Lockfile, source string, selected []discovery.Item, renames map[string]string) error {
own, err := ownDeployed(workDir)
if err != nil {
return fmt.Errorf("scanning own items: %w", err)
}
existing := mergeIndex(own, lockfileDeployed(lf, source))
seen := map[string]string{}
for _, it := range selected {
deployed := deployedName(it.UpstreamID, renames)
if deployed == "" {
return fmt.Errorf("source %q: rename of %q maps to an empty name", source, it.UpstreamID)
}
if prev, dup := seen[deployed]; dup {
return &CollisionError{
Type: it.Type, DeployedID: deployed, Source: source,
OtherOwner: "source " + source, OtherID: prev,
}
}
seen[deployed] = it.UpstreamID
if o, clash := existing.find(it.Type, deployed); clash {
return &CollisionError{
Type: it.Type, DeployedID: deployed, Source: source,
OtherOwner: o.source, OtherID: o.id,
}
}
}
return nil
}
// validateCollisions re-checks the deployed namespace across own items and
// all lockfile sources — the offline equivalent used by validate.
func validateCollisions(workDir string, lf *lockfile.Lockfile) []ValidationError {
own, err := ownDeployed(workDir)
if err != nil {
return []ValidationError{{Rule: "collision", Message: fmt.Sprintf("scanning own items: %v", err)}}
}
idx := mergeIndex(own, deployedIndex{})
var errs []ValidationError
for _, name := range sortedSourceNames(lf) {
src := lf.Sources[name]
for typ, ids := range src.Inventory {
for _, id := range ids {
deployed := deployedName(id, src.Renames)
if prev, dup := idx.find(typ, deployed); dup {
errs = append(errs, ValidationError{
Source: name,
Rule: "collision",
Message: fmt.Sprintf("source %q: %s %q (upstream %q) collides with %s %q",
name, typ, deployed, id, prev.source, prev.id),
})
continue
}
idx.add(typ, deployed, owner{source: name, id: id})
}
}
}
sort.Slice(errs, func(i, j int) bool { return errs[i].Message < errs[j].Message })
return errs
}
// ParseRenames converts --rename upstream-id=deployed-name flag values.
func ParseRenames(pairs []string) (map[string]string, error) {
if len(pairs) == 0 {
return nil, nil
}
out := map[string]string{}
for _, p := range pairs {
upstream, deployed, ok := strings.Cut(p, "=")
if !ok || strings.TrimSpace(upstream) == "" || strings.TrimSpace(deployed) == "" {
return nil, fmt.Errorf("invalid --rename %q (want upstream-id=deployed-name)", p)
}
out[strings.TrimSpace(upstream)] = strings.TrimSpace(deployed)
}
return out, nil
}
+1
View File
@@ -33,6 +33,7 @@ func Validate(workDir string, lf *lockfile.Lockfile) Validation {
v.checkSelection(name, src)
v.checkExternalArea(workDir, name, src)
}
v.Errors = append(v.Errors, validateCollisions(workDir, lf)...)
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