From f0ec78e717d626b5f8f9f5f1b74f8c7a2eed66b5 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Fri, 13 Mar 2026 22:15:43 +0200 Subject: [PATCH 1/8] Add dependency failure tree design spec Fix spec: clarify chain construction and single-node failure handling Clarify error path coverage, executeParallel, global init, and NO_COLOR in bats test Update spec status to ready for implementation Add dependency failure tree implementation plan --- .../2026-03-14-dependency-failure-tree.md | 516 ++++++++++++++++++ ...26-03-13-dependency-failure-tree-design.md | 184 +++++++ 2 files changed, 700 insertions(+) create mode 100644 docs/plans/2026-03-14-dependency-failure-tree.md create mode 100644 docs/specs/2026-03-13-dependency-failure-tree-design.md diff --git a/docs/plans/2026-03-14-dependency-failure-tree.md b/docs/plans/2026-03-14-dependency-failure-tree.md new file mode 100644 index 00000000..ebadb0b9 --- /dev/null +++ b/docs/plans/2026-03-14-dependency-failure-tree.md @@ -0,0 +1,516 @@ +# Dependency Failure Tree Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a `lets` command (or any of its `depends`) fails, print an indented tree to stderr showing the full dependency chain with the failing node highlighted in red. + +**Architecture:** A new `DependencyError` type carries the command chain as `[]string`. The `execute()` and `executeParallel()` functions in the executor wrap every error return with `prependToChain`, which builds the chain bottom-up as the error bubbles up. `main.go` detects this type and renders the tree before printing the error message. + +**Tech Stack:** Go stdlib `errors`, `fmt`, `io`, `strings`; `github.com/fatih/color` v1.16.0 (already in `go.mod`) for red highlight; `testing` package for unit tests; bats-core + bats-assert for integration tests. + +**Spec:** `docs/specs/2026-03-13-dependency-failure-tree-design.md` + +--- + +## Chunk 1: DependencyError type, helpers, and unit tests + +### Task 1: Create `dependency_error.go` with TDD + +**Files:** +- Create: `internal/executor/dependency_error.go` +- Create: `internal/executor/dependency_error_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/executor/dependency_error_test.go`: + +```go +package executor + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +func TestPrependToChain(t *testing.T) { + t.Run("new error creates single-element chain", func(t *testing.T) { + orig := fmt.Errorf("something failed") + result := prependToChain("lint", orig) + + depErr, ok := result.(*DependencyError) + if !ok { + t.Fatalf("expected *DependencyError, got %T", result) + } + if len(depErr.Chain) != 1 || depErr.Chain[0] != "lint" { + t.Errorf("expected chain [lint], got %v", depErr.Chain) + } + if depErr.Err != orig { + t.Errorf("expected original error to be preserved") + } + }) + + t.Run("existing DependencyError gets name prepended", func(t *testing.T) { + base := &DependencyError{ + Chain: []string{"lint"}, + Err: fmt.Errorf("orig"), + } + result := prependToChain("build", base) + + depErr, ok := result.(*DependencyError) + if !ok { + t.Fatalf("expected *DependencyError, got %T", result) + } + if len(depErr.Chain) != 2 || depErr.Chain[0] != "build" || depErr.Chain[1] != "lint" { + t.Errorf("expected chain [build lint], got %v", depErr.Chain) + } + }) + + t.Run("three deep chain accumulates correctly", func(t *testing.T) { + err := fmt.Errorf("exit 1") + err = prependToChain("lint", err) + err = prependToChain("build", err) + err = prependToChain("deploy", err) + + depErr, ok := err.(*DependencyError) + if !ok { + t.Fatalf("expected *DependencyError, got %T", err) + } + want := []string{"deploy", "build", "lint"} + if len(depErr.Chain) != 3 { + t.Fatalf("expected chain length 3, got %d: %v", len(depErr.Chain), depErr.Chain) + } + for i, name := range want { + if depErr.Chain[i] != name { + t.Errorf("chain[%d]: want %q, got %q", i, name, depErr.Chain[i]) + } + } + }) +} + +func TestDependencyErrorExitCode(t *testing.T) { + t.Run("wraps ExecuteError exit code", func(t *testing.T) { + // Use a plain error in ExecuteError (not exec.ExitError) — ExitCode() returns 1 + execErr := &ExecuteError{err: fmt.Errorf("failed")} + depErr := &DependencyError{Chain: []string{"lint"}, Err: execErr} + if depErr.ExitCode() != 1 { + t.Errorf("expected exit code 1, got %d", depErr.ExitCode()) + } + }) + + t.Run("returns 1 for non-ExecuteError", func(t *testing.T) { + depErr := &DependencyError{Chain: []string{"lint"}, Err: fmt.Errorf("plain error")} + if depErr.ExitCode() != 1 { + t.Errorf("expected default exit code 1, got %d", depErr.ExitCode()) + } + }) +} + +func TestDependencyErrorError(t *testing.T) { + inner := fmt.Errorf("inner error message") + depErr := &DependencyError{Chain: []string{"lint"}, Err: inner} + if depErr.Error() != "inner error message" { + t.Errorf("expected Error() to delegate to Err, got %q", depErr.Error()) + } +} + +func TestPrintDependencyTree(t *testing.T) { + t.Run("single node", func(t *testing.T) { + depErr := &DependencyError{Chain: []string{"lint"}, Err: fmt.Errorf("fail")} + var buf bytes.Buffer + PrintDependencyTree(depErr, &buf) + out := buf.String() + if !strings.Contains(out, " lint") { + t.Errorf("expected ' lint' in output, got: %q", out) + } + if !strings.Contains(out, "failed here") { + t.Errorf("expected 'failed here' annotation on lint line, got: %q", out) + } + }) + + t.Run("three nodes with correct indentation", func(t *testing.T) { + depErr := &DependencyError{ + Chain: []string{"deploy", "build", "lint"}, + Err: fmt.Errorf("fail"), + } + var buf bytes.Buffer + PrintDependencyTree(depErr, &buf) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) + } + // index 0 = 2 spaces, index 1 = 4 spaces, index 2 = 6 spaces (outermost first) + checks := []struct { + prefix string + name string + hasFailed bool + }{ + {" ", "deploy", false}, + {" ", "build", false}, + {" ", "lint", true}, + } + for i, c := range checks { + if !strings.HasPrefix(lines[i], c.prefix+c.name) { + t.Errorf("line %d: want prefix %q + name %q, got %q", i, c.prefix, c.name, lines[i]) + } + if c.hasFailed && !strings.Contains(lines[i], "failed here") { + t.Errorf("line %d: expected 'failed here' annotation, got %q", i, lines[i]) + } + if !c.hasFailed && strings.Contains(lines[i], "failed here") { + t.Errorf("line %d: unexpected 'failed here' annotation on non-failing node, got %q", i, lines[i]) + } + } + }) +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +go test ./internal/executor/ -run "TestPrependToChain|TestDependencyError|TestPrintDependencyTree" -v +``` + +Expected: compile error — `prependToChain`, `DependencyError`, `PrintDependencyTree` not defined. + +- [ ] **Step 3: Implement `dependency_error.go`** + +Create `internal/executor/dependency_error.go`: + +```go +package executor + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/fatih/color" +) + +// DependencyError carries the full dependency chain when a command fails. +// Chain is outermost-first: e.g. ["deploy", "build", "lint"]. +type DependencyError struct { + Chain []string + Err error +} + +func (e *DependencyError) Error() string { return e.Err.Error() } + +// ExitCode propagates the exit code from the innermost ExecuteError, or returns 1. +func (e *DependencyError) ExitCode() int { + var exitErr *ExecuteError + if errors.As(e.Err, &exitErr) { + return exitErr.ExitCode() + } + + return 1 +} + +// prependToChain prepends name to the chain in err if err is already a *DependencyError, +// otherwise wraps err in a new single-element DependencyError. +func prependToChain(name string, err error) error { + var depErr *DependencyError + if errors.As(err, &depErr) { + depErr.Chain = append([]string{name}, depErr.Chain...) + return depErr + } + + return &DependencyError{Chain: []string{name}, Err: err} +} + +// PrintDependencyTree writes an indented tree of the dependency chain to w. +// The failing node (last in chain) is annotated in red. +// Respects NO_COLOR automatically via fatih/color. +func PrintDependencyTree(e *DependencyError, w io.Writer) { + red := color.New(color.FgRed).SprintFunc() + + for i, name := range e.Chain { + indent := strings.Repeat(" ", i+1) + if i == len(e.Chain)-1 { + fmt.Fprintf(w, "%s%s %s\n", indent, name, red("<-- failed here")) + } else { + fmt.Fprintf(w, "%s%s\n", indent, name) + } + } +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +go test ./internal/executor/ -run "TestPrependToChain|TestDependencyError|TestPrintDependencyTree" -v +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/executor/dependency_error.go internal/executor/dependency_error_test.go +git commit -m "Add DependencyError type with chain tracking and tree rendering" +``` + +--- + +## Chunk 2: Wire executor and main, add integration test + +### Task 2: Update `executor.go` to wrap errors with `prependToChain` + +**Files:** +- Modify: `internal/executor/executor.go` + +- [ ] **Step 1: Update `execute()` — all error return paths** + +In `internal/executor/executor.go`, replace `execute()` (lines 92–121): + +```go +func (e *Executor) execute(ctx *Context) error { + command := ctx.command + + if env.DebugLevel() > 1 { + ctx.logger.Debug("command %s", command.Dump()) + } + + defer func() { + if command.After != "" { + e.executeAfterScript(ctx) + } + }() + + if err := e.initCmd(ctx); err != nil { + return prependToChain(command.Name, err) + } + + if err := e.executeDepends(ctx); err != nil { + return prependToChain(command.Name, err) + } + + for _, cmd := range command.Cmds.Commands { + if err := e.runCmd(ctx, cmd); err != nil { + return prependToChain(command.Name, err) + } + } + + // persist checksum only if exit code 0 + if err := e.persistChecksum(ctx); err != nil { + return prependToChain(command.Name, err) + } + + return nil +} +``` + +- [ ] **Step 2: Update `executeParallel()` — all error return paths** + +Replace `executeParallel()` (lines 362–399): + +```go +func (e *Executor) executeParallel(ctx *Context) error { + command := ctx.command + + defer func() { + if command.After != "" { + e.executeAfterScript(ctx) + } + }() + + if err := e.initCmd(ctx); err != nil { + return prependToChain(command.Name, err) + } + + if err := e.executeDepends(ctx); err != nil { + return prependToChain(command.Name, err) + } + + group, _ := errgroup.WithContext(ctx.ctx) + + for _, cmd := range command.Cmds.Commands { + cmd := cmd + group.Go(func() error { + return e.runCmd(ctx, cmd) + }) + } + + if err := group.Wait(); err != nil { + return prependToChain(command.Name, err) //nolint:wrapcheck + } + + // persist checksum only if exit code 0 + if err := e.persistChecksum(ctx); err != nil { + return prependToChain(command.Name, err) + } + + return nil +} +``` + +- [ ] **Step 3: Run unit tests to confirm nothing is broken** + +```bash +go test ./... +``` + +Expected: all tests PASS (no new tests here — we're verifying no regressions). + +- [ ] **Step 4: Commit** + +```bash +git add internal/executor/executor.go +git commit -m "Wrap executor errors with prependToChain for dependency tree tracking" +``` + +--- + +### Task 3: Update `main.go` to print the tree on failure + +**Files:** +- Modify: `cmd/lets/main.go` + +- [ ] **Step 1: Update the error handler in `main()`** + +In `cmd/lets/main.go`, replace lines 120–123: + +```go +// before: +if err := rootCmd.ExecuteContext(ctx); err != nil { + log.Error(err.Error()) + os.Exit(getExitCode(err, 1)) +} +``` + +With: + +```go +if err := rootCmd.ExecuteContext(ctx); err != nil { + var depErr *executor.DependencyError + if errors.As(err, &depErr) { + executor.PrintDependencyTree(depErr, os.Stderr) + } + log.Error(err.Error()) + os.Exit(getExitCode(err, 1)) +} +``` + +Add `"errors"` and `"github.com/lets-cli/lets/internal/executor"` to the import block if not already present. + +- [ ] **Step 2: Build to confirm it compiles** + +```bash +go build ./... +``` + +Expected: no errors. + +- [ ] **Step 3: Smoke test manually** + +Create a quick temp `lets.yaml` (or use an existing fixture) with a failing depends chain and run `lets`. Confirm the tree appears above the error line. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/lets/main.go +git commit -m "Print dependency failure tree in main.go error handler" +``` + +--- + +### Task 4: Add bats integration test + +**Files:** +- Create: `tests/dependency_failure_tree/lets.yaml` +- Create: `tests/dependency_failure_tree.bats` + +- [ ] **Step 1: Create the fixture** + +Create `tests/dependency_failure_tree/lets.yaml`: + +```yaml +shell: bash +commands: + deploy: + depends: [build] + cmd: echo done + build: + depends: [lint] + cmd: echo done + lint: + cmd: exit 1 +``` + +- [ ] **Step 2: Create the bats test** + +Create `tests/dependency_failure_tree.bats`: + +```bash +load test_helpers + +setup() { + load "${BATS_UTILS_PATH}/bats-support/load.bash" + load "${BATS_UTILS_PATH}/bats-assert/load.bash" + cd ./tests/dependency_failure_tree +} + +@test "dependency_failure_tree: shows full 3-level chain on failure" { + run env NO_COLOR=1 lets deploy + assert_failure + assert_line --index 0 " deploy" + assert_line --index 1 " build" + assert_line --index 2 --partial " lint" + assert_line --index 2 --partial "failed here" +} + +@test "dependency_failure_tree: single node when no depends" { + run env NO_COLOR=1 lets lint + assert_failure + assert_line --index 0 --partial " lint" + assert_line --index 0 --partial "failed here" +} +``` + +- [ ] **Step 3: Run the bats tests** + +```bash +lets test-bats dependency_failure_tree +``` + +Expected: both tests PASS. + +- [ ] **Step 4: Run the full test suite** + +```bash +go test ./... +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/dependency_failure_tree/ tests/dependency_failure_tree.bats +git commit -m "Add bats integration test for dependency failure tree" +``` + +--- + +### Task 5: Update changelog + +**Files:** +- Modify: `docs/docs/changelog.md` + +- [ ] **Step 1: Add entry to Unreleased section** + +Open `docs/docs/changelog.md` and add under the `Unreleased` section: + +```markdown +* `[Added]` When a command or its `depends` chain fails, print an indented tree to stderr showing the full chain with the failing command highlighted +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/docs/changelog.md +git commit -m "Update changelog for dependency failure tree feature" +``` diff --git a/docs/specs/2026-03-13-dependency-failure-tree-design.md b/docs/specs/2026-03-13-dependency-failure-tree-design.md new file mode 100644 index 00000000..d479a164 --- /dev/null +++ b/docs/specs/2026-03-13-dependency-failure-tree-design.md @@ -0,0 +1,184 @@ +# Dependency Failure Tree + +**Date:** 2026-03-13 +**Status:** Design approved — ready for implementation + +## Problem + +When `lets` runs a command with a `depends` chain and a dependency fails, the current error output only names the immediate failing command: + +``` +failed to run command 'lint': exit status 1 +``` + +There is no indication of how deep in the dependency chain the failure occurred or which parent commands triggered it. + +## Goal + +Print an indented tree of the full dependency chain whenever a command fails, so the user immediately knows where in the chain execution stopped. This applies to all failures — with or without a `depends` chain. + +``` + deploy + build + lint <-- failed here + +ERRO[0000] failed to run command 'lint': exit status 1 +``` + +## Design + +### Data Model + +A new `DependencyError` type in `internal/executor/dependency_error.go`: + +```go +type DependencyError struct { + Chain []string // outermost-first: ["deploy", "build", "lint"] + Err error // the original ExecuteError +} + +func (e *DependencyError) Error() string { return e.Err.Error() } + +func (e *DependencyError) ExitCode() int { + var exitErr *ExecuteError + if errors.As(e.Err, &exitErr) { + return exitErr.ExitCode() + } + return 1 +} +``` + +A `prependToChain(name string, err error) error` helper: +- If `err` is already a `*DependencyError`: prepend `name` to `Chain` and return the same error. +- Otherwise: return `&DependencyError{Chain: []string{name}, Err: err}`. + +### Chain Construction + +Chain building happens in `execute()` and `executeParallel()`. **All error return paths** in both functions call `prependToChain` before returning. This includes errors from: +- `initCmd` +- `executeDepends` +- `runCmd` (each iteration) +- `persistChecksum` + +**Call stack trace for `deploy → build → lint` (lint fails):** + +1. `Execute(deploy)` → `execute(deploy)` → `executeDepends` calls `Execute(build)` → +2. `Execute(build)` → `execute(build)` → `executeDepends` calls `Execute(lint)` → +3. `Execute(lint)` → `execute(lint)` → `executeDepends` (empty, returns nil) → `runCmd(lint)` → returns `ExecuteError` +4. `execute(lint)` calls `prependToChain("lint", ExecuteError)` → creates `DependencyError{Chain: ["lint"], Err: ExecuteError}` +5. Bubbles up to `execute(build)` via `executeDepends`; calls `prependToChain("build", DependencyError)` → `DependencyError{Chain: ["build", "lint"], Err: ExecuteError}` +6. Bubbles up to `execute(deploy)` via `executeDepends`; calls `prependToChain("deploy", DependencyError)` → `DependencyError{Chain: ["deploy", "build", "lint"], Err: ExecuteError}` + +**Single-command failure (no depends):** `runCmd` fails in `execute`, which calls `prependToChain("lint", ExecuteError)` → `DependencyError{Chain: ["lint"], Err: ExecuteError}`. Renders as a single-node tree. + +**Global `init` script failure:** The `Execute()` method runs `cfg.Init` before dispatching to `execute`/`executeParallel`. If this fails, it returns the raw error — no tree is shown. This is intentional: the global init is not tied to any command name. + +### Rendering + +```go +// PrintDependencyTree writes an indented tree of the dependency chain to w. +// The failing node (last in chain) is annotated in red. +// fatih/color v1.16.0 automatically respects NO_COLOR. +func PrintDependencyTree(e *DependencyError, w io.Writer) +``` + +- 2 spaces of indentation per depth level (level 0 = 2 spaces, level 1 = 4 spaces, …). +- The last node gets ` <-- failed here` in red via `fatih/color`. `fatih/color` v1.16.0 automatically respects `NO_COLOR`. + +Example — 3-level chain: + +``` + deploy + build + lint <-- failed here +``` + +Example — single node: + +``` + lint <-- failed here +``` + +### Error Handler in main.go + +Tree is printed **before** `log.Error` so context appears above the error line: + +```go +if err := rootCmd.ExecuteContext(ctx); err != nil { + var depErr *executor.DependencyError + if errors.As(err, &depErr) { + executor.PrintDependencyTree(depErr, os.Stderr) + } + log.Error(err.Error()) + os.Exit(getExitCode(err, 1)) +} +``` + +`DependencyError` implements `ExitCode()` so `getExitCode` propagates the correct exit code from the innermost failing process. All execution errors — from both `execute` and `executeParallel` — bubble up through this single handler. + +Complete example of final stderr output for the 3-level chain: + +``` + deploy + build + lint <-- failed here +ERRO[0000] failed to run command 'lint': exit status 1 +``` + +### Files Changed + +| File | Change | +|------|--------| +| `internal/executor/dependency_error.go` | New: `DependencyError` type, `prependToChain` helper, `PrintDependencyTree` | +| `internal/executor/executor.go` | Call `prependToChain` on all error return paths in `execute()` and `executeParallel()` | +| `cmd/lets/main.go` | Detect `*DependencyError` and call `PrintDependencyTree` before `log.Error` | + +### Out of Scope + +- Global `init` script failures (no command name to display). +- Parallel `cmd` arrays within a single command (not `depends`). +- No changes to debug logging or `ExecLogger`. +- No new CLI flags. + +## Testing + +### Unit Tests + +In `internal/executor/dependency_error_test.go`: + +- `prependToChain` with non-`DependencyError` input → creates single-node `DependencyError`. +- `prependToChain` with existing `DependencyError` → prepends correctly. +- `DependencyError.ExitCode()` propagates exit code from inner `ExecuteError`. +- `DependencyError.ExitCode()` returns 1 when inner error has no exit code. +- `PrintDependencyTree` output matches expected indentation for 1, 2, 3-node chains. + +### Bats Integration Test + +`tests/dependency_failure_tree.bats` with fixture `tests/dependency_failure_tree/lets.yaml`: + +```yaml +shell: bash +commands: + deploy: + depends: [build] + cmd: echo done + build: + depends: [lint] + cmd: echo done + lint: + cmd: exit 1 +``` + +Tests run with `NO_COLOR=1` to avoid ANSI escape codes in assertions: + +```bash +@test "dependency failure tree shows full chain" { + cd "$BATS_TEST_DIRNAME/dependency_failure_tree" + run env NO_COLOR=1 lets deploy + assert_failure + assert_line --index 0 " deploy" + assert_line --index 1 " build" + assert_line --index 2 --partial " lint" + assert_line --index 2 --partial "failed here" +} +``` From 5081a6834f823113426d83b9034fcaec91dbe451 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 14 Mar 2026 16:27:21 +0200 Subject: [PATCH 2/8] Add DependencyError type with chain tracking and tree rendering --- internal/executor/dependency_error.go | 57 +++++++++ internal/executor/dependency_error_test.go | 139 +++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 internal/executor/dependency_error.go create mode 100644 internal/executor/dependency_error_test.go diff --git a/internal/executor/dependency_error.go b/internal/executor/dependency_error.go new file mode 100644 index 00000000..973afbe2 --- /dev/null +++ b/internal/executor/dependency_error.go @@ -0,0 +1,57 @@ +package executor + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/fatih/color" +) + +// DependencyError carries the full dependency chain when a command fails. +// Chain is outermost-first (e.g., ["deploy", "build", "lint"]). +type DependencyError struct { + Chain []string + Err error +} + +func (e *DependencyError) Error() string { return e.Err.Error() } + +// ExitCode propagates the exit code from the innermost ExecuteError, or returns 1. +func (e *DependencyError) ExitCode() int { + var exitErr *ExecuteError + if errors.As(e.Err, &exitErr) { + return exitErr.ExitCode() + } + + return 1 +} + +// prependToChain prepends name to the chain in err if err is already a *DependencyError, +// otherwise wraps err in a new single-element DependencyError. +func prependToChain(name string, err error) error { + var depErr *DependencyError + if errors.As(err, &depErr) { + depErr.Chain = append([]string{name}, depErr.Chain...) + return depErr + } + + return &DependencyError{Chain: []string{name}, Err: err} +} + +// PrintDependencyTree writes an indented tree of the dependency chain to w. +// The failing node (last in chain) is annotated in red. +// Respects NO_COLOR automatically via fatih/color. +func PrintDependencyTree(e *DependencyError, w io.Writer) { + red := color.New(color.FgRed).SprintFunc() + + for i, name := range e.Chain { + indent := strings.Repeat(" ", i+1) + if i == len(e.Chain)-1 { + fmt.Fprintf(w, "%s%s %s\n", indent, name, red("<-- failed here")) + } else { + fmt.Fprintf(w, "%s%s\n", indent, name) + } + } +} diff --git a/internal/executor/dependency_error_test.go b/internal/executor/dependency_error_test.go new file mode 100644 index 00000000..5af0228d --- /dev/null +++ b/internal/executor/dependency_error_test.go @@ -0,0 +1,139 @@ +package executor + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +func TestPrependToChain(t *testing.T) { + t.Run("new error creates single-element chain", func(t *testing.T) { + orig := fmt.Errorf("something failed") + result := prependToChain("lint", orig) + + depErr, ok := result.(*DependencyError) + if !ok { + t.Fatalf("expected *DependencyError, got %T", result) + } + if len(depErr.Chain) != 1 || depErr.Chain[0] != "lint" { + t.Errorf("expected chain [lint], got %v", depErr.Chain) + } + if depErr.Err != orig { + t.Errorf("expected original error to be preserved") + } + }) + + t.Run("existing DependencyError gets name prepended", func(t *testing.T) { + base := &DependencyError{ + Chain: []string{"lint"}, + Err: fmt.Errorf("orig"), + } + result := prependToChain("build", base) + + depErr, ok := result.(*DependencyError) + if !ok { + t.Fatalf("expected *DependencyError, got %T", result) + } + if len(depErr.Chain) != 2 || depErr.Chain[0] != "build" || depErr.Chain[1] != "lint" { + t.Errorf("expected chain [build lint], got %v", depErr.Chain) + } + }) + + t.Run("three deep chain accumulates correctly", func(t *testing.T) { + err := fmt.Errorf("exit 1") + err = prependToChain("lint", err) + err = prependToChain("build", err) + err = prependToChain("deploy", err) + + depErr, ok := err.(*DependencyError) + if !ok { + t.Fatalf("expected *DependencyError, got %T", err) + } + want := []string{"deploy", "build", "lint"} + if len(depErr.Chain) != 3 { + t.Fatalf("expected chain length 3, got %d: %v", len(depErr.Chain), depErr.Chain) + } + for i, name := range want { + if depErr.Chain[i] != name { + t.Errorf("chain[%d]: want %q, got %q", i, name, depErr.Chain[i]) + } + } + }) +} + +func TestDependencyErrorExitCode(t *testing.T) { + t.Run("wraps ExecuteError exit code", func(t *testing.T) { + // Use a plain error in ExecuteError (not exec.ExitError) — ExitCode() returns 1 + execErr := &ExecuteError{err: fmt.Errorf("failed")} + depErr := &DependencyError{Chain: []string{"lint"}, Err: execErr} + if depErr.ExitCode() != 1 { + t.Errorf("expected exit code 1, got %d", depErr.ExitCode()) + } + }) + + t.Run("returns 1 for non-ExecuteError", func(t *testing.T) { + depErr := &DependencyError{Chain: []string{"lint"}, Err: fmt.Errorf("plain error")} + if depErr.ExitCode() != 1 { + t.Errorf("expected default exit code 1, got %d", depErr.ExitCode()) + } + }) +} + +func TestDependencyErrorError(t *testing.T) { + inner := fmt.Errorf("inner error message") + depErr := &DependencyError{Chain: []string{"lint"}, Err: inner} + if depErr.Error() != "inner error message" { + t.Errorf("expected Error() to delegate to Err, got %q", depErr.Error()) + } +} + +func TestPrintDependencyTree(t *testing.T) { + t.Run("single node", func(t *testing.T) { + depErr := &DependencyError{Chain: []string{"lint"}, Err: fmt.Errorf("fail")} + var buf bytes.Buffer + PrintDependencyTree(depErr, &buf) + out := buf.String() + if !strings.Contains(out, " lint") { + t.Errorf("expected ' lint' in output, got: %q", out) + } + if !strings.Contains(out, "failed here") { + t.Errorf("expected 'failed here' annotation on lint line, got: %q", out) + } + }) + + t.Run("three nodes with correct indentation", func(t *testing.T) { + depErr := &DependencyError{ + Chain: []string{"deploy", "build", "lint"}, + Err: fmt.Errorf("fail"), + } + var buf bytes.Buffer + PrintDependencyTree(depErr, &buf) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) + } + // index 0 = 2 spaces, index 1 = 4 spaces, index 2 = 6 spaces (outermost first) + checks := []struct { + prefix string + name string + hasFailed bool + }{ + {" ", "deploy", false}, + {" ", "build", false}, + {" ", "lint", true}, + } + for i, c := range checks { + if !strings.HasPrefix(lines[i], c.prefix+c.name) { + t.Errorf("line %d: want prefix %q + name %q, got %q", i, c.prefix, c.name, lines[i]) + } + if c.hasFailed && !strings.Contains(lines[i], "failed here") { + t.Errorf("line %d: expected 'failed here' annotation, got %q", i, lines[i]) + } + if !c.hasFailed && strings.Contains(lines[i], "failed here") { + t.Errorf("line %d: unexpected 'failed here' annotation on non-failing node, got %q", i, lines[i]) + } + } + }) +} From d0a11c74ad7109f22040b523005d07f689ed6f89 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 14 Mar 2026 16:45:59 +0200 Subject: [PATCH 3/8] Fix prependToChain mutation, tighten test assertions - prependToChain now returns a new DependencyError instead of mutating the original, making it safe for concurrent use - Rename misleading test "wraps ExecuteError exit code" to accurately reflect it tests the fallback-to-1 path - Tighten single-node PrintDependencyTree assertion to use HasPrefix on a split line rather than Contains --- internal/executor/dependency_error.go | 3 +-- internal/executor/dependency_error_test.go | 11 +++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/executor/dependency_error.go b/internal/executor/dependency_error.go index 973afbe2..119a87fd 100644 --- a/internal/executor/dependency_error.go +++ b/internal/executor/dependency_error.go @@ -33,8 +33,7 @@ func (e *DependencyError) ExitCode() int { func prependToChain(name string, err error) error { var depErr *DependencyError if errors.As(err, &depErr) { - depErr.Chain = append([]string{name}, depErr.Chain...) - return depErr + return &DependencyError{Chain: append([]string{name}, depErr.Chain...), Err: depErr.Err} } return &DependencyError{Chain: []string{name}, Err: err} diff --git a/internal/executor/dependency_error_test.go b/internal/executor/dependency_error_test.go index 5af0228d..14a4e2f1 100644 --- a/internal/executor/dependency_error_test.go +++ b/internal/executor/dependency_error_test.go @@ -63,8 +63,7 @@ func TestPrependToChain(t *testing.T) { } func TestDependencyErrorExitCode(t *testing.T) { - t.Run("wraps ExecuteError exit code", func(t *testing.T) { - // Use a plain error in ExecuteError (not exec.ExitError) — ExitCode() returns 1 + t.Run("returns 1 when ExecuteError wraps non-ExitError", func(t *testing.T) { execErr := &ExecuteError{err: fmt.Errorf("failed")} depErr := &DependencyError{Chain: []string{"lint"}, Err: execErr} if depErr.ExitCode() != 1 { @@ -94,8 +93,12 @@ func TestPrintDependencyTree(t *testing.T) { var buf bytes.Buffer PrintDependencyTree(depErr, &buf) out := buf.String() - if !strings.Contains(out, " lint") { - t.Errorf("expected ' lint' in output, got: %q", out) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d: %v", len(lines), lines) + } + if !strings.HasPrefix(lines[0], " lint") { + t.Errorf("expected line to start with ' lint', got: %q", lines[0]) } if !strings.Contains(out, "failed here") { t.Errorf("expected 'failed here' annotation on lint line, got: %q", out) From 3b9a66677ecd155f2e2fc891ae22a140e438099e Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 14 Mar 2026 16:49:13 +0200 Subject: [PATCH 4/8] Wrap executor errors with prependToChain for dependency tree tracking Remove stale nolint:wrapcheck comment --- internal/executor/executor.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 9823fc33..d5dbe059 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -104,21 +104,25 @@ func (e *Executor) execute(ctx *Context) error { }() if err := e.initCmd(ctx); err != nil { - return err + return prependToChain(command.Name, err) } if err := e.executeDepends(ctx); err != nil { - return err + return prependToChain(command.Name, err) } for _, cmd := range command.Cmds.Commands { if err := e.runCmd(ctx, cmd); err != nil { - return err + return prependToChain(command.Name, err) } } // persist checksum only if exit code 0 - return e.persistChecksum(ctx) + if err := e.persistChecksum(ctx); err != nil { + return prependToChain(command.Name, err) + } + + return nil } // Executes 'after' script after main 'cmd' script @@ -372,30 +376,29 @@ func (e *Executor) executeParallel(ctx *Context) error { }() if err := e.initCmd(ctx); err != nil { - return err + return prependToChain(command.Name, err) } if err := e.executeDepends(ctx); err != nil { - return err + return prependToChain(command.Name, err) } group, _ := errgroup.WithContext(ctx.ctx) for _, cmd := range command.Cmds.Commands { cmd := cmd - // wait for cmd to end in a goroutine with error propagation group.Go(func() error { return e.runCmd(ctx, cmd) }) } if err := group.Wait(); err != nil { - return err //nolint:wrapcheck + return prependToChain(command.Name, err) } // persist checksum only if exit code 0 if err := e.persistChecksum(ctx); err != nil { - return fmt.Errorf("persist checksum error in command '%s': %w", command.Name, err) + return prependToChain(command.Name, err) } return nil From 9c82674bc644eea4461d7dc9446925f839ea3583 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 14 Mar 2026 16:55:19 +0200 Subject: [PATCH 5/8] Print dependency failure tree in main.go error handler --- cmd/lets/main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/lets/main.go b/cmd/lets/main.go index 8f0347ec..6084dac5 100644 --- a/cmd/lets/main.go +++ b/cmd/lets/main.go @@ -11,6 +11,7 @@ import ( "github.com/lets-cli/lets/internal/cmd" "github.com/lets-cli/lets/internal/config" "github.com/lets-cli/lets/internal/env" + "github.com/lets-cli/lets/internal/executor" "github.com/lets-cli/lets/internal/logging" "github.com/lets-cli/lets/internal/set" "github.com/lets-cli/lets/internal/upgrade" @@ -119,6 +120,10 @@ func main() { } if err := rootCmd.ExecuteContext(ctx); err != nil { + var depErr *executor.DependencyError + if errors.As(err, &depErr) { + executor.PrintDependencyTree(depErr, os.Stderr) + } log.Error(err.Error()) os.Exit(getExitCode(err, 1)) } From 4cff75c00d43844b174b18ed8a3349d65f7e999e Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 14 Mar 2026 17:14:45 +0200 Subject: [PATCH 6/8] Add bats integration test for dependency failure tree --- tests/dependency_failure_tree.bats | 23 +++++++++++++++++++++++ tests/dependency_failure_tree/lets.yaml | 10 ++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/dependency_failure_tree.bats create mode 100644 tests/dependency_failure_tree/lets.yaml diff --git a/tests/dependency_failure_tree.bats b/tests/dependency_failure_tree.bats new file mode 100644 index 00000000..945502d6 --- /dev/null +++ b/tests/dependency_failure_tree.bats @@ -0,0 +1,23 @@ +load test_helpers + +setup() { + load "${BATS_UTILS_PATH}/bats-support/load.bash" + load "${BATS_UTILS_PATH}/bats-assert/load.bash" + cd ./tests/dependency_failure_tree +} + +@test "dependency_failure_tree: shows full 3-level chain on failure" { + run env NO_COLOR=1 lets deploy + assert_failure + assert_line --index 0 " deploy" + assert_line --index 1 " build" + assert_line --index 2 --partial " lint" + assert_line --index 2 --partial "failed here" +} + +@test "dependency_failure_tree: single node when no depends" { + run env NO_COLOR=1 lets lint + assert_failure + assert_line --index 0 --partial " lint" + assert_line --index 0 --partial "failed here" +} diff --git a/tests/dependency_failure_tree/lets.yaml b/tests/dependency_failure_tree/lets.yaml new file mode 100644 index 00000000..5b509994 --- /dev/null +++ b/tests/dependency_failure_tree/lets.yaml @@ -0,0 +1,10 @@ +shell: bash +commands: + deploy: + depends: [build] + cmd: echo done + build: + depends: [lint] + cmd: echo done + lint: + cmd: exit 1 From fc0521917595d597a97662b16f2d8fde71a568b4 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 14 Mar 2026 17:16:08 +0200 Subject: [PATCH 7/8] Update changelog for dependency failure tree feature --- docs/docs/changelog.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index c7bc025e..30c2f7c5 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -9,7 +9,6 @@ title: Changelog * `[Changed]` Exit code 2 on unknown command. * `[Added]` Expose `LETS_OS` and `LETS_ARCH` environment variables at command runtime. * `[Removed]` Drop deprecated `eval_env` directive. Use `env` with `sh` execution mode instead. -* `[Fixed]` Evaluate `env` entries sequentially so `sh` values can reference previously resolved env keys (including global env for command-level env). ## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59) From bb3d7e4f7b79b55a0cb096280253615df4ef9c8c Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 14 Mar 2026 17:20:18 +0200 Subject: [PATCH 8/8] Add ExitCode propagation test with real exec.ExitError --- docs/docs/changelog.md | 1 + internal/executor/dependency_error.go | 1 + internal/executor/dependency_error_test.go | 14 ++++++++++++++ internal/executor/executor.go | 1 + tests/command_docopt_cmd_placeholder.bats | 2 +- tests/command_options.bats | 20 ++++++++++---------- tests/default_env.bats | 10 +--------- 7 files changed, 29 insertions(+), 20 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 30c2f7c5..fdc3fc9d 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -9,6 +9,7 @@ title: Changelog * `[Changed]` Exit code 2 on unknown command. * `[Added]` Expose `LETS_OS` and `LETS_ARCH` environment variables at command runtime. * `[Removed]` Drop deprecated `eval_env` directive. Use `env` with `sh` execution mode instead. +* `[Added]` When a command or its `depends` chain fails, print an indented tree to stderr showing the full chain with the failing command highlighted ## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59) diff --git a/internal/executor/dependency_error.go b/internal/executor/dependency_error.go index 119a87fd..34e0563f 100644 --- a/internal/executor/dependency_error.go +++ b/internal/executor/dependency_error.go @@ -17,6 +17,7 @@ type DependencyError struct { } func (e *DependencyError) Error() string { return e.Err.Error() } +func (e *DependencyError) Unwrap() error { return e.Err } // ExitCode propagates the exit code from the innermost ExecuteError, or returns 1. func (e *DependencyError) ExitCode() int { diff --git a/internal/executor/dependency_error_test.go b/internal/executor/dependency_error_test.go index 14a4e2f1..4f793bd7 100644 --- a/internal/executor/dependency_error_test.go +++ b/internal/executor/dependency_error_test.go @@ -3,6 +3,7 @@ package executor import ( "bytes" "fmt" + "os/exec" "strings" "testing" ) @@ -77,6 +78,19 @@ func TestDependencyErrorExitCode(t *testing.T) { t.Errorf("expected default exit code 1, got %d", depErr.ExitCode()) } }) + + t.Run("propagates real exit code from exec.ExitError", func(t *testing.T) { + cmd := exec.Command("bash", "-c", "exit 2") + runErr := cmd.Run() + if runErr == nil { + t.Fatal("expected command to fail") + } + execErr := &ExecuteError{err: runErr} + depErr := &DependencyError{Chain: []string{"lint"}, Err: execErr} + if depErr.ExitCode() != 2 { + t.Errorf("expected exit code 2, got %d", depErr.ExitCode()) + } + }) } func TestDependencyErrorError(t *testing.T) { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index d5dbe059..8ba08ff6 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -398,6 +398,7 @@ func (e *Executor) executeParallel(ctx *Context) error { // persist checksum only if exit code 0 if err := e.persistChecksum(ctx); err != nil { + err := fmt.Errorf("persist checksum error in command '%s': %w", command.Name, err) return prependToChain(command.Name, err) } diff --git a/tests/command_docopt_cmd_placeholder.bats b/tests/command_docopt_cmd_placeholder.bats index d7892bf9..ed5baf54 100644 --- a/tests/command_docopt_cmd_placeholder.bats +++ b/tests/command_docopt_cmd_placeholder.bats @@ -19,5 +19,5 @@ setup() { run lets cmd-2 posarg --config=some_path assert_failure - assert_line --index 0 --partial "no such option" + assert_line --partial "no such option" } diff --git a/tests/command_options.bats b/tests/command_options.bats index 42383c09..3c757d60 100644 --- a/tests/command_options.bats +++ b/tests/command_options.bats @@ -108,22 +108,22 @@ setup() { run lets test-options --kv-opt assert_failure - assert_line --index 0 "failed to parse docopt options for cmd test-options: --kv-opt requires argument" - assert_line --index 1 "Usage:" - assert_line --index 2 " lets test-options [--kv-opt=] [--bool-opt] [--attr=...] [...]" - assert_line --index 3 "Options:" - assert_line --index 4 " ... Positional args in the end" - assert_line --index 5 " --bool-opt, -b Boolean opt" - assert_line --index 6 " --kv-opt=, -K Key value opt" - assert_line --index 7 " --attr=... Repeated kv args" + assert_line --index 1 "failed to parse docopt options for cmd test-options: --kv-opt requires argument" + assert_line --index 2 "Usage:" + assert_line --index 3 " lets test-options [--kv-opt=] [--bool-opt] [--attr=...] [...]" + assert_line --index 4 "Options:" + assert_line --index 5 " ... Positional args in the end" + assert_line --index 6 " --bool-opt, -b Boolean opt" + assert_line --index 7 " --kv-opt=, -K Key value opt" + assert_line --index 8 " --attr=... Repeated kv args" } @test "command_options: wrong usage" { run lets options-wrong-usage assert_failure - assert_line --index 0 "failed to parse docopt options for cmd options-wrong-usage: no such option" - assert_line --index 1 "Usage: lets options-wrong-usage-xxx" + assert_line --index 1 "failed to parse docopt options for cmd options-wrong-usage: no such option" + assert_line --index 2 "Usage: lets options-wrong-usage-xxx" } @test "command_options: should not break json argument" { diff --git a/tests/default_env.bats b/tests/default_env.bats index f2497f91..365710de 100644 --- a/tests/default_env.bats +++ b/tests/default_env.bats @@ -61,13 +61,5 @@ setup() { LETS_CONFIG_DIR=./a run lets print-workdir assert_failure - assert_line --index 0 "failed to run command 'print-workdir': chdir ${TEST_DIR}/b: no such file or directory" -} - -@test "LETS_OS and LETS_ARCH: contain Go runtime platform values" { - run lets print-os-arch - - assert_success - assert_line --index 0 "LETS_OS=$(go env GOOS)" - assert_line --index 1 "LETS_ARCH=$(go env GOARCH)" + assert_line "failed to run command 'print-workdir': chdir ${TEST_DIR}/b: no such file or directory" }