Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion boot/deps/lock/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,8 @@ type ModuleLoadPath struct {
// App source has empty Module/Version.
// Replacement paths carry Module from replacement "from" and retain the
// selected lock version when one exists. The replacement changes source
// ownership, not the resolved release identity.
// ownership, not the resolved release identity. Replacements for modules that
// are not selected by the lock graph are resolution inputs, not load paths.
func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath {
lockDir := filepath.Dir(l.path)
replacements := l.effectiveReplacements()
Expand All @@ -439,6 +440,9 @@ func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath {

replaced := make(map[string]struct{}, len(replacements))
for _, repl := range replacements {
if _, selected := selectedVersions[repl.From]; !selected {
continue
}
replaced[repl.From] = struct{}{}
if repl.To != "" {
root := ResolveLockPath(lockDir, repl.To)
Expand Down
40 changes: 27 additions & 13 deletions boot/deps/lock/module_load_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ func TestLock_GetModuleLoadPaths(t *testing.T) {
}
})

t.Run("replacement without src subdirectory loads from replacement root", func(t *testing.T) {
t.Run("workspace-only replacement is excluded from load paths", func(t *testing.T) {
tmpDir := t.TempDir()
lockPath := filepath.Join(tmpDir, DefaultFilename)

Expand All @@ -285,28 +285,42 @@ func TestLock_GetModuleLoadPaths(t *testing.T) {
l.SetDirectories(Directories{Modules: ".wippy", Src: "app"})
l.SetReplacement(Replacement{From: "acme/plain", To: "local/plain"})

paths := l.GetModuleLoadPaths()
if len(paths) != 1 {
t.Fatalf("path count = %d, want only app source", len(paths))
}
loadPaths := l.GetLoadPaths()
if len(loadPaths) != 1 || loadPaths[0] != filepath.Join(tmpDir, "app") {
t.Fatalf("load paths = %v, want only app source", loadPaths)
}
})

t.Run("selected replacement without src loads from replacement root", func(t *testing.T) {
tmpDir := t.TempDir()
l, err := New(filepath.Join(tmpDir, DefaultFilename))
if err != nil {
t.Fatalf("New failed: %v", err)
}

l.SetDirectories(Directories{Modules: ".wippy", Src: "app"})
l.SetModule(Module{Name: "acme/plain", Version: "v1.0.0"})
l.SetReplacement(Replacement{From: "acme/plain", To: "local/plain"})

replacementDir := filepath.Join(tmpDir, "local", "plain")
if err := os.MkdirAll(replacementDir, 0o755); err != nil {
t.Fatalf("mkdir replacement: %v", err)
}

paths := l.GetModuleLoadPaths()
if len(paths) != 2 {
t.Fatalf("path count = %d, want 2", len(paths))
t.Fatalf("path count = %d, want source and selected replacement", len(paths))
}

got := paths[1]
if got.Path != replacementDir {
t.Fatalf("module path = %q, want replacement root %q", got.Path, replacementDir)
if got.Path != replacementDir || got.SourceRoot != replacementDir {
t.Fatalf("replacement path = %+v, want root %q", got, replacementDir)
}
if got.SourceRoot != replacementDir {
t.Fatalf("source root = %q, want replacement root %q", got.SourceRoot, replacementDir)
}
if got.Version != "" {
t.Fatalf("workspace-only replacement version = %q, want empty", got.Version)
}
if !got.Replacement {
t.Fatal("workspace-only source was not marked as replacement")
if got.Module != "acme/plain" || got.Version != "v1.0.0" || !got.Replacement {
t.Fatalf("replacement metadata = %+v", got)
}
})
}
Expand Down
16 changes: 15 additions & 1 deletion boot/deps/lock/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
// Returns an error if any validation fails.
func Validate(l *Lock) error {
rootCount := 0
selectedModules := make(map[string]struct{}, len(l.data.Modules))
for _, mod := range l.data.Modules {
if err := ValidateModuleName(mod.Name); err != nil {
return NewInvalidModuleError(mod.Name, err)
Expand All @@ -23,12 +24,13 @@ func Validate(l *Lock) error {
if mod.Root {
rootCount++
}
selectedModules[mod.Name] = struct{}{}
}
if rootCount > 1 {
return NewMultipleRootModulesError()
}

if err := ValidateReplacements(l.path, l.GetReplacements()); err != nil {
if err := validateReplacements(l.path, l.GetReplacements(), selectedModules); err != nil {
return NewInvalidReplacementsError(err)
}

Expand All @@ -46,6 +48,13 @@ func Validate(l *Lock) error {
// ValidateReplacements checks that all replacement paths exist.
// Paths are resolved relative to the lock file directory.
func ValidateReplacements(lockPath string, replacements []Replacement) error {
return validateReplacements(lockPath, replacements, nil)
}

// validateReplacements always validates declaration shape. When selected is
// non-nil, only replacements in the selected lock graph require a live source
// path; unselected workspace sources are resolver inputs, not boot inputs.
func validateReplacements(lockPath string, replacements []Replacement, selected map[string]struct{}) error {
lockDir := filepath.Dir(lockPath)

for _, r := range replacements {
Expand All @@ -60,6 +69,11 @@ func ValidateReplacements(lockPath string, replacements []Replacement) error {
if err := ValidateModuleName(r.From); err != nil {
return NewReplacementFromInvalidError(r.From, err)
}
if selected != nil {
if _, ok := selected[r.From]; !ok {
continue
}
}

replacementPath := ResolveLockPath(lockDir, r.To)

Expand Down
21 changes: 21 additions & 0 deletions boot/deps/lock/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,34 @@ func TestValidate(t *testing.T) {
lockPath := filepath.Join(tmpDir, "test.lock")

lock, _ := New(lockPath)
lock.SetModule(Module{Name: "wippy/test", Version: "v1.0.0"})
lock.SetReplacement(Replacement{From: "wippy/test", To: "./nonexistent"})

if err := Validate(lock); err == nil {
t.Error("expected error for nonexistent replacement path")
}
})

t.Run("unselected replacement may be absent", func(t *testing.T) {
lock, _ := New(filepath.Join(t.TempDir(), "test.lock"))
lock.SetModule(Module{Name: "wippy/selected", Version: "v1.0.0"})
lock.SetReplacement(Replacement{From: "wippy/unselected", To: "./nonexistent"})

if err := Validate(lock); err != nil {
t.Fatalf("unselected replacement blocked lock validation: %v", err)
}
})

t.Run("unselected replacement declaration remains validated", func(t *testing.T) {
lock, _ := New(filepath.Join(t.TempDir(), "test.lock"))
lock.SetModule(Module{Name: "wippy/selected", Version: "v1.0.0"})
lock.SetReplacement(Replacement{From: "invalid", To: "./nonexistent"})

if err := Validate(lock); err == nil {
t.Fatal("expected malformed unselected replacement to be rejected")
}
})

t.Run("multiple deployment roots fail", func(t *testing.T) {
lock, _ := New(filepath.Join(t.TempDir(), "test.lock"))
lock.SetModule(Module{Name: "acme/app-a", Version: "v1.0.0", Root: true})
Expand Down
21 changes: 12 additions & 9 deletions cmd/wippy/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,22 +502,25 @@ func loadDependencyScanEntries(ctx context.Context, ldr boot.Loader, srcDir stri
}

if lockObj != nil {
replacements := effectiveReplacementModules(lockObj)
for _, mp := range lockObj.GetModuleLoadPaths() {
if mp.Module == "" || !replacements[mp.Module] {
// Update treats every effective replacement as a resolver input, even
// when its module is not selected by the current graph. Unlike boot and
// install, every declared source must therefore be present for this scan.
if err := lock.ValidateReplacements(lockObj.Path(), lockObj.GetReplacements()); err != nil {
return nil, fmt.Errorf("validate replacement dependency sources: %w", err)
}
lockDir := filepath.Dir(lockObj.Path())
for _, replacement := range lockObj.GetReplacements() {
if replacement.To == "" {
continue
}
replacementRoot := mp.SourceRoot
if replacementRoot == "" {
replacementRoot = mp.Path
}
replacementRoot := lock.ResolveLockPath(lockDir, replacement.To)
paths = append(paths, struct {
label string
path string
root string
}{
label: "replacement " + mp.Module,
path: mp.Path,
label: "replacement " + replacement.From,
path: lock.ModuleEntryLoadPath(replacementRoot),
root: replacementRoot,
})
}
Expand Down
19 changes: 19 additions & 0 deletions cmd/wippy/cmd/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,25 @@ entries:
require.Equal(t, []dependencyRequest{{Org: "acme", Module: "runtime", Constraint: "v1.0.0"}}, dependencies)
}

func TestLoadDependencyScanEntriesRequiresUnselectedReplacementSource(t *testing.T) {
ctx := setupLoaderContext(t)
ldr := bootapi.GetLoader(ctx)
require.NotNil(t, ldr)

tmpDir := t.TempDir()
appDir := filepath.Join(tmpDir, "app")
require.NoError(t, os.MkdirAll(appDir, 0o755))
lockObj, err := lock.New(filepath.Join(tmpDir, lock.DefaultFilename), lock.WithWorkspaceReplacements([]lock.Replacement{{
From: "local/component",
To: "missing/component",
}}))
require.NoError(t, err)
lockObj.SetDirectories(lock.Directories{Modules: ".wippy", Src: "app"})

_, err = loadDependencyScanEntries(ctx, ldr, appDir, lockObj, zap.NewNop())
require.ErrorContains(t, err, "validate replacement dependency sources")
}

func TestPruneStaleVendorArtifacts_RemovesStaleArtifacts(t *testing.T) {
tmpDir := t.TempDir()
lockPath := filepath.Join(tmpDir, "wippy.lock")
Expand Down