diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..af401ad --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Pre-commit hook for Seedfast CLI +# +# Enable by running: git config core.hooksPath .githooks +# + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +failed=0 + +echo "Running pre-commit checks..." + +# 1. Check formatting on staged .go files only +staged_go_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.go' || true) +if [ -n "$staged_go_files" ]; then + unformatted="" + for file in $staged_go_files; do + if [ -f "$file" ]; then + output=$(gofmt -l "$file" 2>/dev/null || true) + if [ -n "$output" ]; then + unformatted="$unformatted\n $file" + fi + fi + done + if [ -n "$unformatted" ]; then + echo -e "${RED}FAIL${NC} go fmt: unformatted files detected:${unformatted}" + echo " Run: gofmt -w . (or go fmt ./...)" + failed=1 + else + echo -e "${GREEN}PASS${NC} go fmt" + fi +else + echo -e "${GREEN}SKIP${NC} go fmt (no staged .go files)" +fi + +# 2. go vet +echo -n "Running go vet... " +if ! go vet ./... 2>&1; then + echo -e "${RED}FAIL${NC} go vet" + failed=1 +else + echo -e "${GREEN}PASS${NC} go vet" +fi + +# 3. Production build +echo -n "Running go build... " +if ! go build ./... 2>&1; then + echo -e "${RED}FAIL${NC} go build (production)" + failed=1 +else + echo -e "${GREEN}PASS${NC} go build (production)" +fi + +# 4. Dev build +echo -n "Running go build -tags dev... " +if ! go build -tags dev ./... 2>&1; then + echo -e "${RED}FAIL${NC} go build -tags dev" + failed=1 +else + echo -e "${GREEN}PASS${NC} go build -tags dev" +fi + +# 5. Check for staged docs/ files +staged_docs=$(git diff --cached --name-only -- 'docs/' || true) +if [ -n "$staged_docs" ]; then + echo -e "${RED}FAIL${NC} docs/ files staged for commit (internal docs, not for public repo):" + echo "$staged_docs" | sed 's/^/ /' + echo " Unstage with: git reset HEAD docs/" + failed=1 +else + echo -e "${GREEN}PASS${NC} no docs/ files staged" +fi + +if [ "$failed" -ne 0 ]; then + echo "" + echo -e "${RED}Pre-commit checks failed.${NC} Fix the issues above and try again." + exit 1 +fi + +echo "" +echo -e "${GREEN}All pre-commit checks passed.${NC}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c85f8f..c536df9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,24 @@ permissions: contents: write jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.1' + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + ci: name: CI runs-on: ubuntu-latest @@ -59,5 +77,21 @@ jobs: - name: Vet run: go vet ./... - - name: Test - run: go test ./... -timeout 300s + - name: Test with coverage + run: go test -coverprofile=coverage.out -timeout 300s ./... + + - name: Coverage summary + if: always() + run: | + if [ -f coverage.out ]; then + echo "### Coverage Summary" + go tool cover -func=coverage.out | tail -1 + fi + + - name: Upload coverage profile + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-profile + path: coverage.out + retention-days: 30 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0c9a891 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,20 @@ +run: + timeout: 3m + go: "1.24" + +linters: + enable: + - errcheck + - ineffassign + - staticcheck + - unused + - govet + - gofmt + +issues: + exclude-files: + - ".*\\.pb\\.go$" + exclude-rules: + - path: _test\.go + linters: + - errcheck diff --git a/CLAUDE.md b/CLAUDE.md index 7b1a46c..29c50e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,16 +63,14 @@ The main Claude Code session acts as the team lead. It coordinates all work by s Sequential issue pipeline: -1. **Coder** implements fix -> proactively notifies tester -2. **Tester** designs test plan: - - Define precise expected behavior for each scenario (not "one of these outcomes is fine") - - Proactively consult KB manager for existing knowledge about how the feature should work - - Cover edge cases aggressively -- that's the whole point of a test plan - - Agree expected behavior with team lead before executing - - Never test log levels or implementation details -- only observable behavior - - Submit plan to team lead for approval -> execute after approval -3. **KB manager** proactively updates knowledge base once testing passes -4. Team proceeds to next issue +1. **Coder** implements fix → proactively notifies tester +2. **Tester** designs test plan → submits to team lead for approval → executes after approval +3. **Test results:** + - **PASS** → KB manager updates docs → proceed to next issue + - **FAIL** → tester sends structured error report (see go-tester.md template) → coder +4. **Coder** fixes based on structured report → notifies tester (retry from step 3) +5. **Max 3 iterations** — if tests still fail after 3 coder→tester cycles, escalate to team lead +6. **Team lead** decides: alternative approach, accept with known issues, or architectural change ### Key Team Rules diff --git a/cmd/grpc_test.go b/cmd/grpc_test.go index 2740e7f..a56d953 100644 --- a/cmd/grpc_test.go +++ b/cmd/grpc_test.go @@ -88,12 +88,8 @@ var grpcTestCmd = &cobra.Command{ fmt.Println("\nStep 5: Testing gRPC connection...") tlsCreds := credentials.NewTLS(tlsCfg) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - grpcConn, err := grpc.DialContext(ctx, grpcAddr, + grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(tlsCreds), - grpc.WithBlock(), ) if err != nil { fmt.Printf("❌ gRPC dial failed: %v\n", err) diff --git a/cmd/seed_dev.go b/cmd/seed_dev.go index bf9a466..d49f064 100644 --- a/cmd/seed_dev.go +++ b/cmd/seed_dev.go @@ -4,32 +4,3 @@ //go:build dev package cmd - -import ( - "seedfast/cli/internal/manifest" - - "github.com/pterm/pterm" -) - -// showEndpointIndicators displays visual indicators for endpoint sources in dev builds. -// Shows [PROD] if using production endpoints, or [CUSTOM] if using custom build-time endpoints. -func showEndpointIndicators() { - if manifest.CustomBackendURL != "" || manifest.CustomGrpcURL != "" { - // Custom endpoints - at least one build-time variable is set - pterm.Println(pterm.NewStyle(pterm.FgYellow, pterm.Bold).Sprint("[CUSTOM]") + - pterm.NewStyle(pterm.FgYellow).Sprint(" Using custom endpoints:")) - - if manifest.CustomBackendURL != "" { - pterm.Printf(" → Backend: %s\n", manifest.CustomBackendURL) - } - if manifest.CustomGrpcURL != "" { - pterm.Printf(" → gRPC: %s\n", manifest.CustomGrpcURL) - } - pterm.Println() - } else { - // Production endpoints - both build-time variables are empty - pterm.Println(pterm.NewStyle(pterm.FgGreen, pterm.Bold).Sprint("[PROD]") + - pterm.NewStyle(pterm.FgGreen).Sprint(" Using production endpoints")) - pterm.Println() - } -} diff --git a/cmd/seed_prod.go b/cmd/seed_prod.go index 0df7b31..6487b38 100644 --- a/cmd/seed_prod.go +++ b/cmd/seed_prod.go @@ -4,9 +4,3 @@ //go:build !dev package cmd - -// showEndpointIndicators is a no-op in production builds. -// No visual indicators are shown for endpoint sources. -func showEndpointIndicators() { - // No-op in production builds -} diff --git a/cmd/seed_tui_prod.go b/cmd/seed_tui_prod.go index e6d8222..6487b38 100644 --- a/cmd/seed_tui_prod.go +++ b/cmd/seed_tui_prod.go @@ -4,9 +4,3 @@ //go:build !dev package cmd - -// isTUIOutputMode always returns false in production builds. -// The TUI renderer is only available in dev builds. -func isTUIOutputMode() bool { - return false -} diff --git a/cmd/tuidemo/main.go b/cmd/tuidemo/main.go index c1074b2..3b133fa 100644 --- a/cmd/tuidemo/main.go +++ b/cmd/tuidemo/main.go @@ -170,7 +170,7 @@ func runInteractive(p *tea.Program) { for _, ts := range tables { p.Send(tui.TableStartedMsg{Name: ts.Name, Remaining: len(tables)}) sleep(randMs(800, 1500)) - p.Send(tui.TableDoneMsg{Name: ts.Name, RecordsCount: ts.RecordsCount}) + p.Send(tui.TableDoneMsg(ts)) } sleep(300) @@ -303,7 +303,7 @@ func sendSeedingFlow(p *tea.Program, tables []tui.TableSpec, failures map[string if reason, failed := failures[ts.Name]; failed { p.Send(tui.TableFailedMsg{Name: ts.Name, Reason: reason}) } else { - p.Send(tui.TableDoneMsg{Name: ts.Name, RecordsCount: ts.RecordsCount}) + p.Send(tui.TableDoneMsg(ts)) } } diff --git a/cmd/ui_helpers.go b/cmd/ui_helpers.go index bff6e7d..4fb7f58 100644 --- a/cmd/ui_helpers.go +++ b/cmd/ui_helpers.go @@ -2,59 +2,3 @@ // Licensed under the MIT License. See LICENSE file in the project root for details. package cmd - -import ( - "fmt" - "io" - "sync" - "time" -) - -// startInlineSpinner starts a simple inline spinner animation on a single line. -// It displays rotating animation frames followed by the provided text, updating -// the same line in the terminal. The spinner runs in a separate goroutine and -// can be stopped by calling the returned function. -// -// The spinner automatically clears the line when stopped and handles text length -// limits to prevent display issues. It uses the provided frames array for animation -// and updates at the specified interval. -// -// Parameters: -// - w: The io.Writer to write the spinner to (typically os.Stdout or os.Stderr) -// - text: The text to display after the spinner animation -// - frames: Array of strings representing animation frames (e.g., ["|", "/", "-", "\\"]) -// - interval: Time duration between frame updates -// -// Returns a function that stops the spinner and cleans up when called. -func startInlineSpinner(w io.Writer, text string, frames []string, interval time.Duration) func() { - stop := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - i := 0 - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-stop: - line := fmt.Sprintf("%s %s", frames[i%len(frames)], text) - // Clear the spinner line completely, then return - fmt.Fprintf(w, "\r%*s\r", len(line), "") - return - case <-ticker.C: - line := fmt.Sprintf("%s %s", frames[i%len(frames)], text) - fmt.Fprintf(w, "\r%s", line) - // primitive protection against very long lines - if len(line) > 2000 { - line = line[:2000] - } - i++ - } - } - }() - return func() { - close(stop) - wg.Wait() - } -} diff --git a/internal/dsn/postgres.go b/internal/dsn/postgres.go index 57760d2..88c3537 100644 --- a/internal/dsn/postgres.go +++ b/internal/dsn/postgres.go @@ -26,7 +26,7 @@ func (r *PostgreSQLResolver) Parse(dsn string) (*DSNInfo, error) { // Detect scheme (postgres:// or postgresql://) scheme := "" - remainder := dsn + var remainder string if strings.HasPrefix(dsn, "postgresql://") { scheme = "postgresql" remainder = strings.TrimPrefix(dsn, "postgresql://") diff --git a/internal/manifest/fetcher_dev.go b/internal/manifest/fetcher_dev.go index 9b33a43..d1f03c7 100644 --- a/internal/manifest/fetcher_dev.go +++ b/internal/manifest/fetcher_dev.go @@ -7,4 +7,4 @@ package manifest // defaultManifestURL is the localhost manifest URL for dev builds. // Dev builds default to production URL, but this can be overridden with --backend-url flag. -const defaultManifestURL = "https://dashboard.seedfa.st/cli-endpoints.json" +const defaultManifestURL = "https://seedfa.st/cli-endpoints.json" diff --git a/internal/manifest/fetcher_prod.go b/internal/manifest/fetcher_prod.go index 8240ff0..759ed43 100644 --- a/internal/manifest/fetcher_prod.go +++ b/internal/manifest/fetcher_prod.go @@ -6,4 +6,4 @@ package manifest // defaultManifestURL is the production manifest URL. -const defaultManifestURL = "https://dashboard.seedfa.st/cli-endpoints.json" +const defaultManifestURL = "https://seedfa.st/cli-endpoints.json" diff --git a/internal/mcpserver/handlers.go b/internal/mcpserver/handlers.go index b7eaad8..bb0badf 100644 --- a/internal/mcpserver/handlers.go +++ b/internal/mcpserver/handlers.go @@ -433,7 +433,7 @@ func (s *Server) updateRunFromEvent(ctx context.Context, run *store.Run, event u ElapsedMs: elapsedMs, } if run.Summary.Failures == nil && failedTables > 0 { - // Preserve existing failures + run.Summary.Failures = make(map[string]string) // TODO: Preserve existing failures from prior summary } } @@ -480,7 +480,7 @@ func (s *Server) toolRunStatus(ctx context.Context, args json.RawMessage) (CallT sb.WriteString(fmt.Sprintf("Completed: %s\n", run.CompletedAt.Format(time.RFC3339))) } - sb.WriteString(fmt.Sprintf("\nProgress:\n")) + sb.WriteString("\nProgress:\n") sb.WriteString(fmt.Sprintf(" Tables: %d/%d (%.1f%%)\n", run.Progress.CompletedTables, run.Progress.TotalTables, run.Progress.Percent)) sb.WriteString(fmt.Sprintf(" Rows: %d\n", run.Progress.TotalRows)) @@ -496,7 +496,7 @@ func (s *Server) toolRunStatus(ctx context.Context, args json.RawMessage) (CallT } if run.Summary != nil { - sb.WriteString(fmt.Sprintf("\nSummary:\n")) + sb.WriteString("\nSummary:\n") sb.WriteString(fmt.Sprintf(" Success: %v\n", run.Summary.Success)) sb.WriteString(fmt.Sprintf(" Total Tables: %d\n", run.Summary.TotalTables)) sb.WriteString(fmt.Sprintf(" Succeeded: %d\n", run.Summary.SuccessTables)) @@ -561,7 +561,7 @@ func (s *Server) toolRunCancel(ctx context.Context, args json.RawMessage) (CallT // toolPlansList lists all seeding plans in current session. func (s *Server) toolPlansList(ctx context.Context, args json.RawMessage) (CallToolResult, error) { var input plansListInput - if args != nil && len(args) > 0 { + if len(args) > 0 { if err := json.Unmarshal(args, &input); err != nil { return CallToolResult{}, fmt.Errorf("invalid arguments: %w", err) } @@ -672,7 +672,7 @@ func (s *Server) toolPlanCreate(ctx context.Context, args json.RawMessage) (Call } var sb strings.Builder - sb.WriteString(fmt.Sprintf("Plan created successfully\n")) + sb.WriteString("Plan created successfully\n") sb.WriteString(fmt.Sprintf("Plan ID: %s\n", plan.ID)) sb.WriteString(fmt.Sprintf("Scope: %s\n", plan.Scope)) sb.WriteString(fmt.Sprintf("Tables (%d): %s\n", len(plan.Tables), strings.Join(plan.Tables, ", "))) @@ -721,7 +721,7 @@ func (s *Server) toolPlanUpdate(ctx context.Context, args json.RawMessage) (Call } var sb strings.Builder - sb.WriteString(fmt.Sprintf("Plan updated successfully\n")) + sb.WriteString("Plan updated successfully\n") sb.WriteString(fmt.Sprintf("Plan ID: %s\n", plan.ID)) sb.WriteString(fmt.Sprintf("Scope: %s\n", plan.Scope)) sb.WriteString(fmt.Sprintf("Tables (%d): %s\n", len(plan.Tables), strings.Join(plan.Tables, ", "))) diff --git a/internal/orchestration/auth_validator.go b/internal/orchestration/auth_validator.go index 625c09e..bfc06c0 100644 --- a/internal/orchestration/auth_validator.go +++ b/internal/orchestration/auth_validator.go @@ -44,7 +44,7 @@ func outputAuthError(message, hint, errorCode string) { Message: message + ". " + hint, Code: errorCode, }) - json.NewEncoder(os.Stdout).Encode(event) + _ = json.NewEncoder(os.Stdout).Encode(event) } else { fmt.Println("🔐 " + message) fmt.Println(" " + hint) diff --git a/internal/orchestration/event_handler.go b/internal/orchestration/event_handler.go index 7aa6140..8c26ce9 100644 --- a/internal/orchestration/event_handler.go +++ b/internal/orchestration/event_handler.go @@ -239,7 +239,7 @@ func (eh *EventHandler) handleStreamError(ev seeding.Event) { // Stop UI immediately eh.headerSpinner.Stop() if eh.state.PlanningActive && eh.state.PlanSpinner != nil { - eh.state.PlanSpinner.Stop() + _ = eh.state.PlanSpinner.Stop() eh.state.PlanningActive = false } eh.stopArea() @@ -262,7 +262,7 @@ func (eh *EventHandler) handleStreamError(ev seeding.Event) { // Stop UI immediately eh.headerSpinner.Stop() if eh.state.PlanningActive && eh.state.PlanSpinner != nil { - eh.state.PlanSpinner.Stop() + _ = eh.state.PlanSpinner.Stop() eh.state.PlanningActive = false } eh.stopArea() @@ -282,7 +282,7 @@ func (eh *EventHandler) handleStreamError(ev seeding.Event) { // Stop UI immediately and notify // no prep spinner in the new UI if eh.state.PlanningActive && eh.state.PlanSpinner != nil { - eh.state.PlanSpinner.Stop() + _ = eh.state.PlanSpinner.Stop() eh.state.PlanningActive = false } eh.stopArea() @@ -322,7 +322,7 @@ func (eh *EventHandler) handleSubscriptionBlocked(ev seeding.Event) { // Stop UI immediately eh.headerSpinner.Stop() if eh.state.PlanningActive && eh.state.PlanSpinner != nil { - eh.state.PlanSpinner.Stop() + _ = eh.state.PlanSpinner.Stop() eh.state.PlanningActive = false } eh.stopArea() @@ -345,7 +345,7 @@ func (eh *EventHandler) handleStreamClosed(ev seeding.Event) { // no prep spinner in the new UI if eh.state.PlanningActive && eh.state.PlanSpinner != nil { - eh.state.PlanSpinner.Stop() + _ = eh.state.PlanSpinner.Stop() eh.state.PlanningActive = false } eh.stopArea() @@ -393,7 +393,7 @@ func (eh *EventHandler) handlePlanProposed(ev seeding.Event) error { // Replan/reset UI if eh.state.PlanningActive && eh.state.PlanSpinner != nil { - eh.state.PlanSpinner.Stop() + _ = eh.state.PlanSpinner.Stop() eh.state.PlanningActive = false } eh.stopArea() @@ -493,7 +493,7 @@ func (eh *EventHandler) handleAskHuman(ev seeding.Event) error { // If we were already planning, stop that spinner before showing a new question if eh.state.PlanningActive && eh.state.PlanSpinner != nil { - eh.state.PlanSpinner.Stop() + _ = eh.state.PlanSpinner.Stop() eh.state.PlanningActive = false } @@ -682,7 +682,7 @@ func (eh *EventHandler) handleTableStarted(ev seeding.Event) error { if eh.state.AwaitingDecision { eh.state.AwaitingDecision = false if eh.state.PlanningActive && eh.state.PlanSpinner != nil { - eh.state.PlanSpinner.Stop() + _ = eh.state.PlanSpinner.Stop() eh.state.PlanningActive = false } } diff --git a/internal/orchestration/prompt_runner.go b/internal/orchestration/prompt_runner.go index ca9dfec..ed0167e 100644 --- a/internal/orchestration/prompt_runner.go +++ b/internal/orchestration/prompt_runner.go @@ -2,7 +2,6 @@ package orchestration import ( "context" - "errors" "strings" "time" @@ -21,12 +20,6 @@ type promptResult struct { TimedOut bool } -// Errors returned by RunPrompt. -var ( - errPromptCancelled = errors.New("prompt cancelled by user") - errPromptTimeout = errors.New("prompt input timeout") -) - // PromptConfig configures a standalone prompt run. type PromptConfig struct { QuestionID string diff --git a/internal/orchestration/prompt_runner_test.go b/internal/orchestration/prompt_runner_test.go index 66991a9..42fc0d0 100644 --- a/internal/orchestration/prompt_runner_test.go +++ b/internal/orchestration/prompt_runner_test.go @@ -1,6 +1,7 @@ package orchestration import ( + "context" "strings" "testing" @@ -19,7 +20,7 @@ func TestPromptActions_SendHumanResponse_EmptyAnswer(t *testing.T) { "answer": map[string]any{"raw": ""}, } - err := pa.SendHumanResponse(nil, "q-1", responseData) + err := pa.SendHumanResponse(context.TODO(), "q-1", responseData) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -45,7 +46,7 @@ func TestPromptActions_SendHumanResponse_NonEmptyAnswer(t *testing.T) { "answer": map[string]any{"raw": "use English"}, } - err := pa.SendHumanResponse(nil, "q-1", responseData) + err := pa.SendHumanResponse(context.TODO(), "q-1", responseData) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -68,7 +69,7 @@ func TestPromptActions_SendHumanResponse_MissingAnswerKey(t *testing.T) { "other_key": 123, } - err := pa.SendHumanResponse(nil, "q-1", responseData) + err := pa.SendHumanResponse(context.TODO(), "q-1", responseData) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -91,7 +92,7 @@ func TestPromptActions_SendHumanResponse_MalformedAnswer(t *testing.T) { "answer": "not a map", } - err := pa.SendHumanResponse(nil, "q-1", responseData) + err := pa.SendHumanResponse(context.TODO(), "q-1", responseData) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -108,7 +109,7 @@ func TestPromptActions_SendHumanResponse_MalformedAnswer(t *testing.T) { func TestPromptActions_SendCancellation_UserCancelled(t *testing.T) { pa := &promptActions{resultCh: make(chan promptResult, 1)} - err := pa.SendCancellation(nil, "user cancelled") + err := pa.SendCancellation(context.TODO(), "user cancelled") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -125,7 +126,7 @@ func TestPromptActions_SendCancellation_UserCancelled(t *testing.T) { func TestPromptActions_SendCancellation_InputTimedOut(t *testing.T) { pa := &promptActions{resultCh: make(chan promptResult, 1)} - err := pa.SendCancellation(nil, "input timed out") + err := pa.SendCancellation(context.TODO(), "input timed out") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -142,7 +143,7 @@ func TestPromptActions_SendCancellation_InputTimedOut(t *testing.T) { func TestPromptActions_SendCancellation_OtherReason(t *testing.T) { pa := &promptActions{resultCh: make(chan promptResult, 1)} - err := pa.SendCancellation(nil, "some other reason") + err := pa.SendCancellation(context.TODO(), "some other reason") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -163,7 +164,7 @@ func TestPromptActions_NonBlockingWhenChannelFull(t *testing.T) { pa.resultCh <- promptResult{Answer: "first"} // Second send should not block (non-blocking select) - err := pa.SendHumanResponse(nil, "q-1", map[string]any{ + err := pa.SendHumanResponse(context.TODO(), "q-1", map[string]any{ "answer": map[string]any{"raw": "second"}, }) if err != nil { @@ -490,12 +491,11 @@ func TestStripAnsi_IncompleteEscape(t *testing.T) { got := stripAnsi(input) // The function should not panic; exact output depends on implementation // but should contain the non-escape characters - if len(got) == 0 { - // The \033 and [ will be consumed looking for 'm', and since - // there's no 'm', the loop exits. The remaining characters from - // index 0 onward where \033 is found get appended including \033 byte. - // As long as no panic, we're good. - } + // The \033 and [ will be consumed looking for 'm', and since + // there's no 'm', the loop exits. The remaining characters from + // index 0 onward where \033 is found get appended including \033 byte. + // As long as no panic, we're good. + _ = got // Main check: no panic occurred } diff --git a/internal/orchestration/seed_orchestrator.go b/internal/orchestration/seed_orchestrator.go index 190d20b..7638f99 100644 --- a/internal/orchestration/seed_orchestrator.go +++ b/internal/orchestration/seed_orchestrator.go @@ -42,8 +42,6 @@ type SeedOrchestrator struct { authValidator *AuthValidator dsnResolver *DSNResolver dbConnector *DatabaseConnector - eventHandler *EventHandler - workerPool *WorkerPool signalHandler *SignalHandler // Runtime state diff --git a/internal/seeding/renderer.go b/internal/seeding/renderer.go index 3894343..ab428d7 100644 --- a/internal/seeding/renderer.go +++ b/internal/seeding/renderer.go @@ -3,10 +3,6 @@ package seeding -import ( - "github.com/pterm/pterm" -) - // Renderer renders seeding events to console with docker-compose-like UI. type Renderer struct { sectionShown bool @@ -43,10 +39,3 @@ func (r *Renderer) Render(ev Event) { // Suppressed to keep UI clean } } - -func stringListToBulletItems(items []string) (out []pterm.BulletListItem) { - for _, s := range items { - out = append(out, pterm.BulletListItem{Level: 0, Text: s}) - } - return out -} diff --git a/internal/sqlexec/executor.go b/internal/sqlexec/executor.go index 6890947..fc15a06 100644 --- a/internal/sqlexec/executor.go +++ b/internal/sqlexec/executor.go @@ -175,7 +175,7 @@ func (e *Executor) ExecuteSQLInSchema(ctx context.Context, sql string, isWrite b jsonBytes, _ := res.MarshalJSON() return string(jsonBytes), nil } - defer tx.Rollback(ctx) // Rollback if commit doesn't happen + defer func() { _ = tx.Rollback(ctx) }() // Rollback if commit doesn't happen ct, err := tx.Exec(ctx, sql) if err != nil { diff --git a/internal/ui/tui/app_test.go b/internal/ui/tui/app_test.go index 1a22efe..3998e51 100644 --- a/internal/ui/tui/app_test.go +++ b/internal/ui/tui/app_test.go @@ -16,12 +16,9 @@ var _ tea.Model = AppModel{} // stubPhase is a minimal Phase implementation for testing that tracks calls // and optionally triggers a transition on specific message types. type stubPhase struct { - id PhaseID - initCalled bool - viewText string - transitionOn tea.Msg // if set, trigger transition to transitionTarget on this msg type - transitionTarget PhaseID - transitionData interface{} + id PhaseID + initCalled bool + viewText string } func newStubPhase(id PhaseID, viewText string) *stubPhase { @@ -37,7 +34,7 @@ func (p *stubPhase) Init() tea.Cmd { func (p *stubPhase) Update(msg tea.Msg) (Phase, tea.Cmd, *Transition) { // Check if this message type should trigger a transition. - switch msg.(type) { + switch msg := msg.(type) { case SubscriptionLimitMsg: if p.id == PhaseInit { return p, nil, &Transition{To: PhaseSubscription, Data: msg} @@ -55,8 +52,7 @@ func (p *stubPhase) Update(msg tea.Msg) (Phase, tea.Cmd, *Transition) { return p, nil, &Transition{To: PhaseSeeding, Data: msg} } case StreamErrorMsg: - m := msg.(StreamErrorMsg) - return p, nil, &Transition{To: PhaseError, Data: m.Message} + return p, nil, &Transition{To: PhaseError, Data: msg.Message} case WorkflowCompletedMsg: if p.id == PhaseSeeding { return p, nil, &Transition{To: PhaseCompleted, Data: "done"} @@ -94,10 +90,7 @@ func newTestAppModel() AppModel { func TestAppModelImplementsTeaModel(t *testing.T) { m := newTestAppModel() - var model tea.Model = m - if model == nil { - t.Fatal("AppModel should implement tea.Model") - } + var _ tea.Model = m } func TestAppModelInitialPhase(t *testing.T) { diff --git a/internal/ui/tui/components/selector_test.go b/internal/ui/tui/components/selector_test.go index 3dd759e..7333fdf 100644 --- a/internal/ui/tui/components/selector_test.go +++ b/internal/ui/tui/components/selector_test.go @@ -114,7 +114,7 @@ func TestSelector_EnterSelectsCurrentOption(t *testing.T) { // Navigate to index 1 s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyDown}) - s, _, result := s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _, _, result := s.Update(tea.KeyMsg{Type: tea.KeyEnter}) if result == nil { t.Fatal("expected a SelectorResult on Enter") diff --git a/internal/ui/tui/components/summary_box.go b/internal/ui/tui/components/summary_box.go index e6050bc..da06a7d 100644 --- a/internal/ui/tui/components/summary_box.go +++ b/internal/ui/tui/components/summary_box.go @@ -15,14 +15,13 @@ const dashboardURL = "https://dashboard.seedfa.st" // SummaryBoxComponent renders the completion or failure summary. type SummaryBoxComponent struct { - success bool - tableCount int - failedCount int - skippedCount int - totalRows int - elapsed time.Duration - failures map[string]string - theme *tui.Theme + success bool + tableCount int + failedCount int + totalRows int + elapsed time.Duration + failures map[string]string + theme *tui.Theme } func NewSuccessSummary(theme *tui.Theme, tableCount, totalRows int, elapsed time.Duration) SummaryBoxComponent { diff --git a/internal/ui/tui/integration_test.go b/internal/ui/tui/integration_test.go index a689c59..824ce5c 100644 --- a/internal/ui/tui/integration_test.go +++ b/internal/ui/tui/integration_test.go @@ -61,15 +61,6 @@ func assertViewContains(t *testing.T, m tea.Model, expected string) { } } -// assertViewNotContains checks that the model's View() output does NOT contain the substring. -func assertViewNotContains(t *testing.T, m tea.Model, unexpected string) { - t.Helper() - view := m.View() - if strings.Contains(view, unexpected) { - t.Errorf("View() should not contain %q.\nView output:\n%s", unexpected, view) - } -} - // subscriptionLimitMsg returns a typical SubscriptionLimitMsg for testing. func subscriptionLimitMsg() tui.SubscriptionLimitMsg { remaining := int32(10) diff --git a/internal/ui/tui/phases/init_phase.go b/internal/ui/tui/phases/init_phase.go index daff4ff..c0db3d9 100644 --- a/internal/ui/tui/phases/init_phase.go +++ b/internal/ui/tui/phases/init_phase.go @@ -33,7 +33,7 @@ func (p *InitPhase) Init() tea.Cmd { } func (p *InitPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) { - switch msg.(type) { + switch msg := msg.(type) { case tui.SubscriptionLimitMsg: p.showSpinner = false // hide before transition so history view is clean return p, nil, &tui.Transition{To: tui.PhaseSubscription, Data: msg} @@ -43,8 +43,7 @@ func (p *InitPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) { return p, nil, &tui.Transition{To: tui.PhasePrompt, Data: msg} case tui.StreamErrorMsg: p.showSpinner = false - m := msg.(tui.StreamErrorMsg) - return p, nil, &tui.Transition{To: tui.PhaseError, Data: m.Message} + return p, nil, &tui.Transition{To: tui.PhaseError, Data: msg.Message} case tui.SpinnerStartMsg: p.showSpinner = true return p, nil, nil diff --git a/internal/ui/tui/phases/plan_phase.go b/internal/ui/tui/phases/plan_phase.go index f650c5a..55cbcaf 100644 --- a/internal/ui/tui/phases/plan_phase.go +++ b/internal/ui/tui/phases/plan_phase.go @@ -58,7 +58,7 @@ func (p *PlanPhase) Init() tea.Cmd { } func (p *PlanPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) { - switch msg.(type) { + switch msg := msg.(type) { case tui.AskHumanMsg: p.showSpinner = false // hide before transition so history view is clean return p, nil, &tui.Transition{To: tui.PhasePrompt, Data: msg} @@ -77,8 +77,7 @@ func (p *PlanPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) { return p, nil, nil case tui.StreamErrorMsg: p.showSpinner = false - m := msg.(tui.StreamErrorMsg) - return p, nil, &tui.Transition{To: tui.PhaseError, Data: m.Message} + return p, nil, &tui.Transition{To: tui.PhaseError, Data: msg.Message} } if p.showSpinner { diff --git a/internal/ui/tui/phases/prompt_phase.go b/internal/ui/tui/phases/prompt_phase.go index 50c8799..8ca79e5 100644 --- a/internal/ui/tui/phases/prompt_phase.go +++ b/internal/ui/tui/phases/prompt_phase.go @@ -102,7 +102,7 @@ func (p *PromptPhase) Init() tea.Cmd { func (p *PromptPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) { // Handle events from gRPC that arrive while in prompt phase - switch msg.(type) { + switch msg := msg.(type) { case tui.HumanResponseSentMsg: // Response sent, wait for next gRPC event p.waiting = true @@ -128,8 +128,7 @@ func (p *PromptPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) return p, nil, nil case tui.StreamErrorMsg: p.waiting = false - m := msg.(tui.StreamErrorMsg) - return p, nil, &tui.Transition{To: tui.PhaseError, Data: m.Message} + return p, nil, &tui.Transition{To: tui.PhaseError, Data: msg.Message} case tui.CancellationSentMsg: return p, tea.Quit, nil } diff --git a/internal/ui/tui/phases/subscription_phase.go b/internal/ui/tui/phases/subscription_phase.go index 7044a9e..2af807b 100644 --- a/internal/ui/tui/phases/subscription_phase.go +++ b/internal/ui/tui/phases/subscription_phase.go @@ -95,7 +95,7 @@ func (p *SubscriptionPhase) Init() tea.Cmd { } func (p *SubscriptionPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) { - switch msg.(type) { + switch msg := msg.(type) { case tui.PlanProposedMsg: p.showSpinner = false // hide before transition so history view is clean return p, nil, &tui.Transition{To: tui.PhasePlan, Data: msg} @@ -107,8 +107,7 @@ func (p *SubscriptionPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transi return p, nil, &tui.Transition{To: tui.PhaseError, Data: msg} case tui.StreamErrorMsg: p.showSpinner = false - m := msg.(tui.StreamErrorMsg) - return p, nil, &tui.Transition{To: tui.PhaseError, Data: m.Message} + return p, nil, &tui.Transition{To: tui.PhaseError, Data: msg.Message} case tui.SpinnerStartMsg: p.showSpinner = true return p, nil, nil diff --git a/internal/updates/cache.go b/internal/updates/cache.go index 7a18255..380c249 100644 --- a/internal/updates/cache.go +++ b/internal/updates/cache.go @@ -137,7 +137,7 @@ func SaveNotificationData(data *NotificationData) error { } // Write to file (ignore errors - not critical) - os.WriteFile(path, jsonData, 0644) + _ = os.WriteFile(path, jsonData, 0644) return nil } diff --git a/internal/updates/checker.go b/internal/updates/checker.go index b87f5b6..4df75c5 100644 --- a/internal/updates/checker.go +++ b/internal/updates/checker.go @@ -61,7 +61,7 @@ func CheckAndNotifyIfNeeded(ctx context.Context, cmd *cobra.Command, currentVers // Update cache with fetched version and check time data.LatestVersion = latestVersion data.LastCheckTime = time.Now() - SaveNotificationData(data) + _ = SaveNotificationData(data) } // Determine if we should show notification @@ -74,7 +74,7 @@ func CheckAndNotifyIfNeeded(ctx context.Context, cmd *cobra.Command, currentVers // Update cache with new notification time data.LastNotificationTime = time.Now() - SaveNotificationData(data) + _ = SaveNotificationData(data) } // isCacheFresh returns true if the cached version data is recent enough to skip