diff --git a/boot/deps/lock/errors.go b/boot/deps/lock/errors.go index f7ae8baa6..1295bc734 100644 --- a/boot/deps/lock/errors.go +++ b/boot/deps/lock/errors.go @@ -46,6 +46,14 @@ func NewCheckReplacementPathError(path string, cause error) apierror.Error { return apierror.New(apierror.Internal, fmt.Sprintf("failed to check replacement path %q", path)).WithCause(cause) } +func NewReadOverlayFileError(cause error) apierror.Error { + return apierror.New(apierror.Internal, "failed to read overlay lock file").WithCause(cause) +} + +func NewUnmarshalOverlayError(cause error) apierror.Error { + return apierror.New(apierror.Invalid, "failed to parse overlay lock file (wippy.lock.local may only contain replacements)").WithCause(cause) +} + func NewInvalidFormatError(name string) apierror.Error { return apierror.New(apierror.Invalid, fmt.Sprintf("invalid format (expected org/module, got %q)", name)) } diff --git a/boot/deps/lock/lock.go b/boot/deps/lock/lock.go index b92a8c528..8861dce8b 100644 --- a/boot/deps/lock/lock.go +++ b/boot/deps/lock/lock.go @@ -3,6 +3,9 @@ package lock import ( + "bytes" + "errors" + "io" "os" "path/filepath" "sort" @@ -14,10 +17,16 @@ import ( // DefaultFilename is the standard name for wippy lock files. const DefaultFilename = "wippy.lock" +// OverlaySuffix is appended to the lock file path to locate the local, +// gitignored overlay (e.g. wippy.lock -> wippy.lock.local) that carries +// developer-local replacements without touching the tracked lock. +const OverlaySuffix = ".local" + // Lock represents a lock file with operations for reading, writing, and querying. type Lock struct { - path string - data File + path string + overlay []Replacement + data File } // New creates a new Lock instance from the given path. @@ -48,9 +57,70 @@ func New(path string) (*Lock, error) { return nil, NewStatLockFileError(err) } + if err := lock.readOverlay(); err != nil { + return nil, err + } + return lock, nil } +// readOverlay loads developer-local replacements from the ".local" overlay +// when present. The overlay may only contain a replacements section; any other +// key is rejected. Overlay replacements are kept separate from the tracked data +// so they are never written back into the lock file. +func (l *Lock) readOverlay() error { + overlayPath := l.path + OverlaySuffix + + data, err := os.ReadFile(overlayPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return NewReadOverlayFileError(err) + } + + var overlay OverlayFile + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&overlay); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return NewUnmarshalOverlayError(err) + } + + l.overlay = overlay.Replacements + return nil +} + +// effectiveReplacements returns the tracked replacements overlaid with the local +// overlay, where overlay entries win by "from". Order is preserved: tracked +// entries first (updated in place when overridden), then overlay-only additions. +func (l *Lock) effectiveReplacements() []Replacement { + if len(l.overlay) == 0 { + return l.data.Replacements + } + + index := make(map[string]int, len(l.data.Replacements)+len(l.overlay)) + merged := make([]Replacement, 0, len(l.data.Replacements)+len(l.overlay)) + + for _, r := range l.data.Replacements { + index[r.From] = len(merged) + merged = append(merged, r) + } + + for _, r := range l.overlay { + if i, ok := index[r.From]; ok { + merged[i] = r + continue + } + index[r.From] = len(merged) + merged = append(merged, r) + } + + return merged +} + // Read loads the lock file from disk. func (l *Lock) Read() error { data, err := os.ReadFile(l.path) @@ -158,10 +228,11 @@ func (l *Lock) GetModules() []Module { return l.data.Modules } -// GetReplacement retrieves a replacement by from field. +// GetReplacement retrieves a replacement by from field from the effective set +// (tracked replacements overlaid with the local overlay). // Returns the replacement and true if found, zero value and false otherwise. func (l *Lock) GetReplacement(from string) (Replacement, bool) { - for _, r := range l.data.Replacements { + for _, r := range l.effectiveReplacements() { if r.From == from { return r, true } @@ -192,8 +263,16 @@ func (l *Lock) RemoveReplacement(from string) { l.data.Replacements = filtered } -// GetReplacements returns all replacements. +// GetReplacements returns the effective replacements: the tracked replacements +// overlaid with the local overlay (overlay entries win by "from"). func (l *Lock) GetReplacements() []Replacement { + return l.effectiveReplacements() +} + +// GetTrackedReplacements returns only the replacements persisted in the lock +// file, excluding any local overlay. Used where the overlay must never +// participate, such as regenerating the tracked lock during update. +func (l *Lock) GetTrackedReplacements() []Replacement { return l.data.Replacements } @@ -314,7 +393,8 @@ type ModuleLoadPath struct { // Replacement paths carry Module from replacement "from" and empty Version. func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { lockDir := filepath.Dir(l.path) - paths := make([]ModuleLoadPath, 0, 1+len(l.data.Replacements)+len(l.data.Modules)) + replacements := l.effectiveReplacements() + paths := make([]ModuleLoadPath, 0, 1+len(replacements)+len(l.data.Modules)) if l.data.Directories.Src != "" { paths = append(paths, ModuleLoadPath{ @@ -322,7 +402,9 @@ func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { }) } - for _, repl := range l.data.Replacements { + replaced := make(map[string]struct{}, len(replacements)) + for _, repl := range replacements { + replaced[repl.From] = struct{}{} if repl.To != "" { root := ResolveLockPath(lockDir, repl.To) path := moduleEntryLoadPath(root) @@ -338,7 +420,7 @@ func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { fullVendorDir := ResolveLockPath(lockDir, vendorDir) for _, mod := range l.data.Modules { - if _, hasReplacement := l.GetReplacement(mod.Name); hasReplacement { + if _, hasReplacement := replaced[mod.Name]; hasReplacement { continue } diff --git a/boot/deps/lock/overlay_test.go b/boot/deps/lock/overlay_test.go new file mode 100644 index 000000000..c1bfeaa1d --- /dev/null +++ b/boot/deps/lock/overlay_test.go @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: MPL-2.0 + +package lock + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestNew_LoadsOverlayReplacements(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +modules: + - name: wippy/x + version: v1.0.0 +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./x-local +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + r, ok := l.GetReplacement("wippy/x") + if !ok { + t.Fatal("expected overlay replacement for wippy/x") + } + if r.To != "./x-local" { + t.Errorf("expected ./x-local, got %q", r.To) + } +} + +func TestNew_OverlayWinsOverTrackedReplacement(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +replacements: + - from: wippy/x + to: ./tracked +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./overlay +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + r, _ := l.GetReplacement("wippy/x") + if r.To != "./overlay" { + t.Errorf("expected overlay to win with ./overlay, got %q", r.To) + } + + if len(l.GetReplacements()) != 1 { + t.Errorf("expected 1 effective replacement, got %d", len(l.GetReplacements())) + } +} + +func TestGetTrackedReplacements_ExcludesOverlay(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +replacements: + - from: wippy/tracked + to: ./tracked +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/overlay + to: ./overlay +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + tracked := l.GetTrackedReplacements() + if len(tracked) != 1 || tracked[0].From != "wippy/tracked" { + t.Fatalf("expected only tracked replacement, got %+v", tracked) + } + + if len(l.GetReplacements()) != 2 { + t.Errorf("expected 2 effective replacements, got %d", len(l.GetReplacements())) + } +} + +func TestGetModuleLoadPaths_ReflectsOverlay(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +modules: + - name: wippy/x + version: v1.0.0 +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./x-local +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + var replPath string + var sawVendorX bool + for _, mp := range l.GetModuleLoadPaths() { + if mp.Module == "wippy/x" { + if mp.Version == "" { + replPath = mp.Path + } else { + sawVendorX = true + } + } + } + + if sawVendorX { + t.Error("overlay-replaced module must not be loaded from vendor") + } + if !strings.HasSuffix(replPath, filepath.Join(tmpDir, "x-local")) && !strings.Contains(replPath, "x-local") { + t.Errorf("expected load path under x-local, got %q", replPath) + } +} + +func TestWrite_DoesNotPersistOverlay(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +modules: + - name: wippy/x + version: v1.0.0 +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./x-local +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := l.Write(); err != nil { + t.Fatalf("Write: %v", err) + } + + data, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("read back: %v", err) + } + if strings.Contains(string(data), "x-local") || strings.Contains(string(data), "replacements") { + t.Errorf("tracked lock must not contain overlay content, got:\n%s", data) + } + + reloaded, err := New(lockPath) + if err != nil { + t.Fatalf("reload: %v", err) + } + if len(reloaded.GetTrackedReplacements()) != 0 { + t.Errorf("expected no tracked replacements after write, got %d", len(reloaded.GetTrackedReplacements())) + } +} + +func TestNew_StrictOverlayRejectsNonReplacementKeys(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +`) + writeFile(t, lockPath+OverlaySuffix, `modules: + - name: wippy/x + version: v1.0.0 +replacements: + - from: wippy/x + to: ./x-local +`) + + _, err := New(lockPath) + if err == nil { + t.Fatal("expected error when overlay declares non-replacement keys") + } +} + +func TestNew_NoOverlayEffectiveEqualsTracked(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +replacements: + - from: wippy/x + to: ./tracked +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + if len(l.GetReplacements()) != len(l.GetTrackedReplacements()) { + t.Error("without overlay, effective replacements must equal tracked") + } + r, _ := l.GetReplacement("wippy/x") + if r.To != "./tracked" { + t.Errorf("expected ./tracked, got %q", r.To) + } +} + +func TestNew_OverlayPathDerivedFromCustomLockName(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "custom.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./x-local +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, ok := l.GetReplacement("wippy/x"); !ok { + t.Error("expected overlay next to custom.lock (custom.lock.local) to load") + } +} + +func TestNew_EmptyOverlayFileIsTolerated(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {"zero bytes", ""}, + {"comment only", "# SPDX-License-Identifier: MPL-2.0\n"}, + {"whitespace only", "\n\n \n"}, + } { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +replacements: + - from: wippy/x + to: ./tracked +`) + writeFile(t, lockPath+OverlaySuffix, tc.content) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("empty overlay must be tolerated, got %v", err) + } + if len(l.GetReplacements()) != 1 { + t.Errorf("expected tracked replacement to remain, got %d", len(l.GetReplacements())) + } + }) + } +} + +func TestValidate_OverlayReplacementPathChecked(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./missing-x +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := Validate(l); err == nil { + t.Fatal("expected validation error for missing overlay replacement path") + } + + if err := os.Mkdir(filepath.Join(tmpDir, "missing-x"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := Validate(l); err != nil { + t.Errorf("expected validation to pass once overlay path exists, got %v", err) + } +} diff --git a/boot/deps/lock/types.go b/boot/deps/lock/types.go index e2e03587c..609073eb7 100644 --- a/boot/deps/lock/types.go +++ b/boot/deps/lock/types.go @@ -10,6 +10,12 @@ type File struct { Options Options `yaml:"options,omitempty"` } +// OverlayFile represents the structure of a wippy.lock.local overlay file. +// It may only carry replacements; any other key is rejected on load. +type OverlayFile struct { + Replacements []Replacement `yaml:"replacements"` +} + // Options specifies runtime behavior for module loading. type Options struct { UnpackModules bool `yaml:"unpack_modules,omitempty"` // Extract .wapp to directories (default: false) diff --git a/boot/deps/lock/validate.go b/boot/deps/lock/validate.go index 61840ee6f..34a0c94d5 100644 --- a/boot/deps/lock/validate.go +++ b/boot/deps/lock/validate.go @@ -21,7 +21,7 @@ func Validate(l *Lock) error { } } - if err := ValidateReplacements(l.path, l.data.Replacements); err != nil { + if err := ValidateReplacements(l.path, l.GetReplacements()); err != nil { return NewInvalidReplacementsError(err) } diff --git a/cmd/wippy/cmd/gitignore.go b/cmd/wippy/cmd/gitignore.go new file mode 100644 index 000000000..715e76a5d --- /dev/null +++ b/cmd/wippy/cmd/gitignore.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "bufio" + "bytes" + "os" + "path/filepath" + + "github.com/wippyai/runtime/boot/deps/lock" +) + +// ensureOverlayGitignored idempotently ensures the .gitignore next to the lock +// file lists the local overlay (e.g. wippy.lock.local) so machine-local +// replacement overrides never get committed. It creates the .gitignore when +// absent and appends the entry on its own line when missing, preserving any +// existing content. +func ensureOverlayGitignored(lockPath string) error { + entry := filepath.Base(lockPath) + lock.OverlaySuffix + gitignorePath := filepath.Join(filepath.Dir(lockPath), ".gitignore") + + existing, err := os.ReadFile(gitignorePath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + return os.WriteFile(gitignorePath, []byte(entry+"\n"), 0644) + } + + scanner := bufio.NewScanner(bytes.NewReader(existing)) + for scanner.Scan() { + if scanner.Text() == entry { + return nil + } + } + if err := scanner.Err(); err != nil { + return err + } + + var buf bytes.Buffer + buf.Write(existing) + if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) { + buf.WriteByte('\n') + } + buf.WriteString(entry + "\n") + + return os.WriteFile(gitignorePath, buf.Bytes(), 0644) +} diff --git a/cmd/wippy/cmd/gitignore_test.go b/cmd/wippy/cmd/gitignore_test.go new file mode 100644 index 000000000..264357a6f --- /dev/null +++ b/cmd/wippy/cmd/gitignore_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wippyai/runtime/boot/deps/lock" +) + +func countOccurrences(s, sub string) int { + return strings.Count(s, sub) +} + +func TestEnsureOverlayGitignored_CreatesFile(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + + if err := ensureOverlayGitignored(lockPath); err != nil { + t.Fatalf("ensureOverlayGitignored: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, ".gitignore")) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + if !strings.Contains(string(data), "wippy.lock.local") { + t.Errorf("expected wippy.lock.local entry, got:\n%s", data) + } +} + +func TestEnsureOverlayGitignored_AppendsPreservingContent(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + gitignorePath := filepath.Join(tmpDir, ".gitignore") + + if err := os.WriteFile(gitignorePath, []byte("node_modules/\n.wippy/\n"), 0o644); err != nil { + t.Fatalf("seed .gitignore: %v", err) + } + + if err := ensureOverlayGitignored(lockPath); err != nil { + t.Fatalf("ensureOverlayGitignored: %v", err) + } + + data, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + content := string(data) + if !strings.Contains(content, "node_modules/") || !strings.Contains(content, ".wippy/") { + t.Errorf("existing content lost:\n%s", content) + } + if !strings.Contains(content, "wippy.lock.local") { + t.Errorf("entry not appended:\n%s", content) + } +} + +func TestEnsureOverlayGitignored_Idempotent(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + gitignorePath := filepath.Join(tmpDir, ".gitignore") + + for i := 0; i < 3; i++ { + if err := ensureOverlayGitignored(lockPath); err != nil { + t.Fatalf("ensureOverlayGitignored run %d: %v", i, err) + } + } + + data, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + if n := countOccurrences(string(data), "wippy.lock.local"); n != 1 { + t.Errorf("expected exactly one entry, got %d:\n%s", n, data) + } +} + +func TestEnsureOverlayGitignored_NoTrailingNewline(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + gitignorePath := filepath.Join(tmpDir, ".gitignore") + + if err := os.WriteFile(gitignorePath, []byte(".wippy/"), 0o644); err != nil { + t.Fatalf("seed .gitignore: %v", err) + } + + if err := ensureOverlayGitignored(lockPath); err != nil { + t.Fatalf("ensureOverlayGitignored: %v", err) + } + + data, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + if !strings.Contains(string(data), ".wippy/\nwippy.lock.local") { + t.Errorf("entry must land on its own line:\n%s", data) + } +} diff --git a/cmd/wippy/cmd/init.go b/cmd/wippy/cmd/init.go index 7b2282f51..f75ff25a2 100644 --- a/cmd/wippy/cmd/init.go +++ b/cmd/wippy/cmd/init.go @@ -70,6 +70,10 @@ func runInit(cmd *cobra.Command, _ []string) error { return NewWriteLockFileError(fmt.Errorf("lock file %s: %w", lockObj.Path(), err)) } + if err := ensureOverlayGitignored(lockObj.Path()); err != nil { + logger.Warn("failed to update .gitignore for local overlay", zap.Error(err)) + } + logger.Info("lock file initialized successfully") return nil } diff --git a/cmd/wippy/cmd/update.go b/cmd/wippy/cmd/update.go index da2fb441d..b8d486008 100644 --- a/cmd/wippy/cmd/update.go +++ b/cmd/wippy/cmd/update.go @@ -146,7 +146,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Build set of replaced modules to exclude from hub resolution replacedModules := make(map[string]bool) if oldLockObj != nil { - for _, repl := range oldLockObj.GetReplacements() { + for _, repl := range oldLockObj.GetTrackedReplacements() { replacedModules[repl.From] = true } } @@ -196,7 +196,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Preserve all replacements from old lock file if oldLockObj != nil { - preserveReplacements(newLockObj, oldLockObj.GetReplacements()) + preserveReplacements(newLockObj, oldLockObj.GetTrackedReplacements()) } // Save lock file @@ -316,7 +316,7 @@ func runTargetedUpdate(cmd *cobra.Command, lockFilePath, srcDir, modulesDir stri oldLockObj, _ := lock.New(lockFilePath) replacedModules := make(map[string]bool) - for _, repl := range lockObj.GetReplacements() { + for _, repl := range lockObj.GetTrackedReplacements() { replacedModules[repl.From] = true } @@ -416,7 +416,7 @@ func runTargetedUpdate(cmd *cobra.Command, lockFilePath, srcDir, modulesDir stri } // Preserve all replacements from current lock file - preserveReplacements(newLockObj, lockObj.GetReplacements()) + preserveReplacements(newLockObj, lockObj.GetTrackedReplacements()) // Detect changes changes := lock.Diff(oldLockObj, newLockObj) @@ -498,7 +498,7 @@ func loadDependencyScanEntries(ctx context.Context, ldr boot.Loader, srcDir stri if lockObj != nil { replacements := make(map[string]bool) - for _, repl := range lockObj.GetReplacements() { + for _, repl := range lockObj.GetTrackedReplacements() { replacements[repl.From] = true } for _, mp := range lockObj.GetModuleLoadPaths() { diff --git a/cmd/wippy/cmd/update_test.go b/cmd/wippy/cmd/update_test.go index f940f9fcc..fd2f4f8b2 100644 --- a/cmd/wippy/cmd/update_test.go +++ b/cmd/wippy/cmd/update_test.go @@ -205,6 +205,87 @@ entries: } } +func TestLoadDependencyScanEntriesExcludesOverlayReplacementSources(t *testing.T) { + ctx := setupLoaderContext(t) + ldr := bootapi.GetLoader(ctx) + if ldr == nil { + t.Fatal("loader not available in test context") + } + + tmpDir := t.TempDir() + appDir := filepath.Join(tmpDir, "app") + overlayReplSrc := filepath.Join(tmpDir, "local", "overlay-ui", "src") + for _, dir := range []string{appDir, overlayReplSrc} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + + appYAML := `version: "1.0" +namespace: app.deps +entries: + - name: facade + kind: ns.dependency + component: wippy/facade + version: ">=v0.5.39" +` + if err := os.WriteFile(filepath.Join(appDir, "_index.yaml"), []byte(appYAML), 0o644); err != nil { + t.Fatalf("write app index: %v", err) + } + + overlayYAML := `version: "1.0" +namespace: overlay.ui.deps +entries: + - name: overlaydep + kind: ns.dependency + component: wippy/overlaydep + version: ">=v0.1.0" +` + if err := os.WriteFile(filepath.Join(overlayReplSrc, "_index.yaml"), []byte(overlayYAML), 0o644); err != nil { + t.Fatalf("write overlay index: %v", err) + } + + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + if err := os.WriteFile(lockPath, []byte(`directories: + modules: .wippy + src: app +modules: + - name: acme/ui + version: v1.0.0 +`), 0o644); err != nil { + t.Fatalf("write lock: %v", err) + } + if err := os.WriteFile(lockPath+lock.OverlaySuffix, []byte(`replacements: + - from: acme/ui + to: local/overlay-ui +`), 0o644); err != nil { + t.Fatalf("write overlay lock: %v", err) + } + + lockObj, err := lock.New(lockPath) + if err != nil { + t.Fatalf("create lock: %v", err) + } + + loaded, err := loadDependencyScanEntries(ctx, ldr, appDir, lockObj, zap.NewNop()) + if err != nil { + t.Fatalf("loadDependencyScanEntries failed: %v", err) + } + + deps := extractRootDependencies(loaded, payload.GetTranscoder(ctx)) + got := map[string]string{} + for _, dep := range deps { + got[dep.Org+"/"+dep.Module] = dep.Constraint + } + + if got["wippy/facade"] != ">=v0.5.39" { + t.Fatalf("app dependency missing: got %v", got) + } + if _, ok := got["wippy/overlaydep"]; ok { + t.Fatalf("update must not scan overlay replacement sources: got %v", got) + } +} + func mustWriteFile(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {