diff --git a/boot/deps/lock/lock.go b/boot/deps/lock/lock.go index 52c6c4d77..28f9505a1 100644 --- a/boot/deps/lock/lock.go +++ b/boot/deps/lock/lock.go @@ -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() @@ -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) diff --git a/boot/deps/lock/module_load_paths_test.go b/boot/deps/lock/module_load_paths_test.go index 5c9376d1f..a2d889d74 100644 --- a/boot/deps/lock/module_load_paths_test.go +++ b/boot/deps/lock/module_load_paths_test.go @@ -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) @@ -285,6 +285,27 @@ 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) @@ -292,21 +313,14 @@ func TestLock_GetModuleLoadPaths(t *testing.T) { 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) } }) } diff --git a/boot/deps/lock/validate.go b/boot/deps/lock/validate.go index f3c59ef6b..e7441a3ce 100644 --- a/boot/deps/lock/validate.go +++ b/boot/deps/lock/validate.go @@ -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) @@ -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) } @@ -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 { @@ -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) diff --git a/boot/deps/lock/validate_test.go b/boot/deps/lock/validate_test.go index d4fea4216..4533037f5 100644 --- a/boot/deps/lock/validate_test.go +++ b/boot/deps/lock/validate_test.go @@ -87,6 +87,7 @@ 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 { @@ -94,6 +95,26 @@ func TestValidate(t *testing.T) { } }) + 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}) diff --git a/cmd/wippy/cmd/update.go b/cmd/wippy/cmd/update.go index 3417dbd3c..793d8e9c7 100644 --- a/cmd/wippy/cmd/update.go +++ b/cmd/wippy/cmd/update.go @@ -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, }) } diff --git a/cmd/wippy/cmd/update_test.go b/cmd/wippy/cmd/update_test.go index e293d2712..fb11215ee 100644 --- a/cmd/wippy/cmd/update_test.go +++ b/cmd/wippy/cmd/update_test.go @@ -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")