diff --git a/.gitignore b/.gitignore index 32c03e0..00b5b21 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,8 @@ dist/ tasks /core /i18n-validate -cmd/ +cmd/* +!cmd/gocmd/ .angular/ patch_cov.* diff --git a/cmd/gocmd/cmd_commands.go b/cmd/gocmd/cmd_commands.go new file mode 100644 index 0000000..44b6fb3 --- /dev/null +++ b/cmd/gocmd/cmd_commands.go @@ -0,0 +1,21 @@ +// Package gocmd provides Go development commands with enhanced output. +// +// Note: Package named gocmd because 'go' is a reserved keyword. +// +// Commands: +// - test: Run tests with colour-coded coverage summary +// - cov: Run tests with detailed coverage reports (HTML, thresholds) +// - fmt: Format code using goimports or gofmt +// - lint: Run golangci-lint +// - install: Install binary to $GOPATH/bin +// - mod: Module management (tidy, download, verify, graph) +// - work: Workspace management (sync, init, use) +// +// Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS. +package gocmd + +import "forge.lthn.ai/core/go/pkg/cli" + +func init() { + cli.RegisterCommands(AddGoCommands) +} diff --git a/cmd/gocmd/cmd_format.go b/cmd/gocmd/cmd_format.go new file mode 100644 index 0000000..ff5dc2b --- /dev/null +++ b/cmd/gocmd/cmd_format.go @@ -0,0 +1,177 @@ +package gocmd + +import ( + "bufio" + "os" + "os/exec" + "path/filepath" + "strings" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" +) + +var ( + fmtFix bool + fmtDiff bool + fmtCheck bool + fmtAll bool +) + +func addGoFmtCommand(parent *cli.Command) { + fmtCmd := &cli.Command{ + Use: "fmt", + Short: "Format Go code", + Long: "Format Go code using goimports or gofmt. By default only checks changed files.", + RunE: func(cmd *cli.Command, args []string) error { + // Get list of files to check + var files []string + if fmtAll { + // Check all Go files + files = []string{"."} + } else { + // Only check changed Go files (git-aware) + files = getChangedGoFiles() + if len(files) == 0 { + cli.Print("%s\n", i18n.T("cmd.go.fmt.no_changes")) + return nil + } + } + + // Validate flag combinations + if fmtCheck && fmtFix { + return cli.Err("--check and --fix are mutually exclusive") + } + + fmtArgs := []string{} + if fmtFix { + fmtArgs = append(fmtArgs, "-w") + } + if fmtDiff { + fmtArgs = append(fmtArgs, "-d") + } + if !fmtFix && !fmtDiff { + fmtArgs = append(fmtArgs, "-l") + } + fmtArgs = append(fmtArgs, files...) + + // Try goimports first, fall back to gofmt + var execCmd *exec.Cmd + if _, err := exec.LookPath("goimports"); err == nil { + execCmd = exec.Command("goimports", fmtArgs...) + } else { + execCmd = exec.Command("gofmt", fmtArgs...) + } + + // For --check mode, capture output to detect unformatted files + if fmtCheck { + output, err := execCmd.CombinedOutput() + if err != nil { + _, _ = os.Stderr.Write(output) + return err + } + if len(output) > 0 { + _, _ = os.Stdout.Write(output) + return cli.Err("files need formatting (use --fix)") + } + return nil + } + + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + }, + } + + fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix")) + fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff")) + fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, i18n.T("cmd.go.fmt.flag.check")) + fmtCmd.Flags().BoolVar(&fmtAll, "all", false, i18n.T("cmd.go.fmt.flag.all")) + + parent.AddCommand(fmtCmd) +} + +// getChangedGoFiles returns Go files that have been modified, staged, or are untracked. +func getChangedGoFiles() []string { + var files []string + + // Get modified and staged files + cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD") + output, err := cmd.Output() + if err == nil { + files = append(files, filterGoFiles(string(output))...) + } + + // Get untracked files + cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard") + output, err = cmd.Output() + if err == nil { + files = append(files, filterGoFiles(string(output))...) + } + + // Deduplicate + seen := make(map[string]bool) + var unique []string + for _, f := range files { + if !seen[f] { + seen[f] = true + // Verify file exists (might have been deleted) + if _, err := os.Stat(f); err == nil { + unique = append(unique, f) + } + } + } + + return unique +} + +// filterGoFiles filters a newline-separated list of files to only include .go files. +func filterGoFiles(output string) []string { + var goFiles []string + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + file := strings.TrimSpace(scanner.Text()) + if file != "" && filepath.Ext(file) == ".go" { + goFiles = append(goFiles, file) + } + } + return goFiles +} + +var ( + lintFix bool + lintAll bool +) + +func addGoLintCommand(parent *cli.Command) { + lintCmd := &cli.Command{ + Use: "lint", + Short: "Run golangci-lint", + Long: "Run golangci-lint for comprehensive static analysis. By default only lints changed files.", + RunE: func(cmd *cli.Command, args []string) error { + lintArgs := []string{"run"} + if lintFix { + lintArgs = append(lintArgs, "--fix") + } + + if !lintAll { + // Use --new-from-rev=HEAD to only report issues in uncommitted changes + // This is golangci-lint's native way to handle incremental linting + lintArgs = append(lintArgs, "--new-from-rev=HEAD") + } + + // Always lint all packages + lintArgs = append(lintArgs, "./...") + + execCmd := exec.Command("golangci-lint", lintArgs...) + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + }, + } + + lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix")) + lintCmd.Flags().BoolVar(&lintAll, "all", false, i18n.T("cmd.go.lint.flag.all")) + + parent.AddCommand(lintCmd) +} diff --git a/cmd/gocmd/cmd_fuzz.go b/cmd/gocmd/cmd_fuzz.go new file mode 100644 index 0000000..ce909ca --- /dev/null +++ b/cmd/gocmd/cmd_fuzz.go @@ -0,0 +1,169 @@ +package gocmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" +) + +var ( + fuzzDuration time.Duration + fuzzPkg string + fuzzRun string + fuzzVerbose bool +) + +func addGoFuzzCommand(parent *cli.Command) { + fuzzCmd := &cli.Command{ + Use: "fuzz", + Short: "Run Go fuzz tests", + Long: `Run Go fuzz tests with configurable duration. + +Discovers Fuzz* functions across the project and runs each with go test -fuzz. + +Examples: + core go fuzz # Run all fuzz targets for 10s each + core go fuzz --duration=30s # Run each target for 30s + core go fuzz --pkg=./pkg/... # Fuzz specific package + core go fuzz --run=FuzzE # Run only matching fuzz targets`, + RunE: func(cmd *cli.Command, args []string) error { + return runGoFuzz(fuzzDuration, fuzzPkg, fuzzRun, fuzzVerbose) + }, + } + + fuzzCmd.Flags().DurationVar(&fuzzDuration, "duration", 10*time.Second, "Duration per fuzz target") + fuzzCmd.Flags().StringVar(&fuzzPkg, "pkg", "", "Package to fuzz (default: auto-discover)") + fuzzCmd.Flags().StringVar(&fuzzRun, "run", "", "Only run fuzz targets matching pattern") + fuzzCmd.Flags().BoolVarP(&fuzzVerbose, "verbose", "v", false, "Verbose output") + + parent.AddCommand(fuzzCmd) +} + +// fuzzTarget represents a discovered fuzz function and its package. +type fuzzTarget struct { + Pkg string + Name string +} + +func runGoFuzz(duration time.Duration, pkg, run string, verbose bool) error { + cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fuzz")), i18n.ProgressSubject("run", "fuzz tests")) + cli.Blank() + + targets, err := discoverFuzzTargets(pkg, run) + if err != nil { + return cli.Wrap(err, "discover fuzz targets") + } + + if len(targets) == 0 { + cli.Print(" %s no fuzz targets found\n", dimStyle.Render("—")) + return nil + } + + cli.Print(" %s %d target(s), %s each\n", dimStyle.Render(i18n.Label("targets")), len(targets), duration) + cli.Blank() + + passed := 0 + failed := 0 + + for _, t := range targets { + cli.Print(" %s %s in %s\n", dimStyle.Render("→"), t.Name, t.Pkg) + + args := []string{ + "test", + fmt.Sprintf("-fuzz=^%s$", t.Name), + fmt.Sprintf("-fuzztime=%s", duration), + "-run=^$", // Don't run unit tests + } + if verbose { + args = append(args, "-v") + } + args = append(args, t.Pkg) + + cmd := exec.Command("go", args...) + cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0") + cmd.Dir, _ = os.Getwd() + + output, runErr := cmd.CombinedOutput() + outputStr := string(output) + + if runErr != nil { + failed++ + cli.Print(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), runErr.Error()) + if outputStr != "" { + cli.Text(outputStr) + } + } else { + passed++ + cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass")) + if verbose && outputStr != "" { + cli.Text(outputStr) + } + } + } + + cli.Blank() + if failed > 0 { + cli.Print("%s %d passed, %d failed\n", errorStyle.Render(cli.Glyph(":cross:")), passed, failed) + return cli.Err("fuzz: %d target(s) failed", failed) + } + + cli.Print("%s %d passed\n", successStyle.Render(cli.Glyph(":check:")), passed) + return nil +} + +// discoverFuzzTargets scans for Fuzz* functions in test files. +func discoverFuzzTargets(pkg, pattern string) ([]fuzzTarget, error) { + root := "." + if pkg != "" { + // Convert Go package pattern to filesystem path + root = strings.TrimPrefix(pkg, "./") + root = strings.TrimSuffix(root, "/...") + } + + fuzzRe := regexp.MustCompile(`^func\s+(Fuzz\w+)\s*\(\s*\w+\s+\*testing\.F\s*\)`) + var matchRe *regexp.Regexp + if pattern != "" { + var err error + matchRe, err = regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid --run pattern: %w", err) + } + } + + var targets []fuzzTarget + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() || !strings.HasSuffix(info.Name(), "_test.go") { + return nil + } + + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + + dir := "./" + filepath.Dir(path) + for line := range strings.SplitSeq(string(data), "\n") { + m := fuzzRe.FindStringSubmatch(line) + if m == nil { + continue + } + name := m[1] + if matchRe != nil && !matchRe.MatchString(name) { + continue + } + targets = append(targets, fuzzTarget{Pkg: dir, Name: name}) + } + return nil + }) + return targets, err +} diff --git a/cmd/gocmd/cmd_go.go b/cmd/gocmd/cmd_go.go new file mode 100644 index 0000000..2c2fbec --- /dev/null +++ b/cmd/gocmd/cmd_go.go @@ -0,0 +1,36 @@ +// Package gocmd provides Go development commands. +// +// Note: Package named gocmd because 'go' is a reserved keyword. +package gocmd + +import ( + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// Style aliases for shared styles +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + dimStyle = cli.DimStyle +) + +// AddGoCommands adds Go development commands. +func AddGoCommands(root *cli.Command) { + goCmd := &cli.Command{ + Use: "go", + Short: i18n.T("cmd.go.short"), + Long: i18n.T("cmd.go.long"), + } + + root.AddCommand(goCmd) + addGoQACommand(goCmd) + addGoTestCommand(goCmd) + addGoCovCommand(goCmd) + addGoFmtCommand(goCmd) + addGoLintCommand(goCmd) + addGoInstallCommand(goCmd) + addGoModCommand(goCmd) + addGoWorkCommand(goCmd) + addGoFuzzCommand(goCmd) +} diff --git a/cmd/gocmd/cmd_gotest.go b/cmd/gocmd/cmd_gotest.go new file mode 100644 index 0000000..52971a1 --- /dev/null +++ b/cmd/gocmd/cmd_gotest.go @@ -0,0 +1,430 @@ +package gocmd + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" +) + +var ( + testCoverage bool + testPkg string + testRun string + testShort bool + testRace bool + testJSON bool + testVerbose bool +) + +func addGoTestCommand(parent *cli.Command) { + testCmd := &cli.Command{ + Use: "test", + Short: "Run Go tests", + Long: "Run Go tests with optional coverage, filtering, and race detection", + RunE: func(cmd *cli.Command, args []string) error { + return runGoTest(testCoverage, testPkg, testRun, testShort, testRace, testJSON, testVerbose) + }, + } + + testCmd.Flags().BoolVar(&testCoverage, "coverage", false, "Generate coverage report") + testCmd.Flags().StringVar(&testPkg, "pkg", "", "Package to test") + testCmd.Flags().StringVar(&testRun, "run", "", "Run only tests matching pattern") + testCmd.Flags().BoolVar(&testShort, "short", false, "Run only short tests") + testCmd.Flags().BoolVar(&testRace, "race", false, "Enable race detector") + testCmd.Flags().BoolVar(&testJSON, "json", false, "Output as JSON") + testCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, "Verbose output") + + parent.AddCommand(testCmd) +} + +func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error { + if pkg == "" { + pkg = "./..." + } + + args := []string{"test"} + + var covPath string + if coverage { + args = append(args, "-cover", "-covermode=atomic") + covFile, err := os.CreateTemp("", "coverage-*.out") + if err == nil { + covPath = covFile.Name() + _ = covFile.Close() + args = append(args, "-coverprofile="+covPath) + defer os.Remove(covPath) + } + } + + if run != "" { + args = append(args, "-run", run) + } + if short { + args = append(args, "-short") + } + if race { + args = append(args, "-race") + } + if verbose { + args = append(args, "-v") + } + + args = append(args, pkg) + + if !jsonOut { + cli.Print("%s %s\n", dimStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests")) + cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), pkg) + cli.Blank() + } + + cmd := exec.Command("go", args...) + cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0") + cmd.Dir, _ = os.Getwd() + + output, err := cmd.CombinedOutput() + outputStr := string(output) + + // Filter linker warnings + lines := strings.Split(outputStr, "\n") + var filtered []string + for _, line := range lines { + if !strings.Contains(line, "ld: warning:") { + filtered = append(filtered, line) + } + } + outputStr = strings.Join(filtered, "\n") + + // Parse results + passed, failed, skipped := parseTestResults(outputStr) + cov := parseOverallCoverage(outputStr) + + if jsonOut { + cli.Print(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`, + passed, failed, skipped, cov, cmd.ProcessState.ExitCode()) + cli.Blank() + return err + } + + // Print filtered output if verbose or failed + if verbose || err != nil { + cli.Text(outputStr) + } + + // Summary + if err == nil { + cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass")) + } else { + cli.Print(" %s %s, %s\n", errorStyle.Render(cli.Glyph(":cross:")), + i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"), + i18n.T("i18n.count.test", failed)+" "+i18n.T("i18n.done.fail")) + } + + if cov > 0 { + cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(cov)) + if covPath != "" { + branchCov, err := calculateBlockCoverage(covPath) + if err != nil { + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), cli.ErrorStyle.Render("unable to calculate")) + } else { + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov)) + } + } + } + + if err == nil { + cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass"))) + } else { + cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.done.fail"))) + } + + return err +} + +func parseTestResults(output string) (passed, failed, skipped int) { + passRe := regexp.MustCompile(`(?m)^ok\s+`) + failRe := regexp.MustCompile(`(?m)^FAIL\s+`) + skipRe := regexp.MustCompile(`(?m)^\?\s+`) + + passed = len(passRe.FindAllString(output, -1)) + failed = len(failRe.FindAllString(output, -1)) + skipped = len(skipRe.FindAllString(output, -1)) + return +} + +func parseOverallCoverage(output string) float64 { + re := regexp.MustCompile(`coverage:\s+([\d.]+)%`) + matches := re.FindAllStringSubmatch(output, -1) + if len(matches) == 0 { + return 0 + } + + var total float64 + for _, m := range matches { + var cov float64 + _, _ = fmt.Sscanf(m[1], "%f", &cov) + total += cov + } + return total / float64(len(matches)) +} + +var ( + covPkg string + covHTML bool + covOpen bool + covThreshold float64 + covBranchThreshold float64 + covOutput string +) + +func addGoCovCommand(parent *cli.Command) { + covCmd := &cli.Command{ + Use: "cov", + Short: "Run tests with coverage report", + Long: "Run tests with detailed coverage reports, HTML output, and threshold checking", + RunE: func(cmd *cli.Command, args []string) error { + pkg := covPkg + if pkg == "" { + // Auto-discover packages with tests + pkgs, err := findTestPackages(".") + if err != nil { + return cli.Wrap(err, i18n.T("i18n.fail.find", "test packages")) + } + if len(pkgs) == 0 { + return errors.New("no test packages found") + } + pkg = strings.Join(pkgs, " ") + } + + // Create temp file for coverage data + covFile, err := os.CreateTemp("", "coverage-*.out") + if err != nil { + return cli.Wrap(err, i18n.T("i18n.fail.create", "coverage file")) + } + covPath := covFile.Name() + _ = covFile.Close() + defer func() { + if covOutput == "" { + _ = os.Remove(covPath) + } else { + // Copy to output destination before removing + src, _ := os.Open(covPath) + dst, _ := os.Create(covOutput) + if src != nil && dst != nil { + _, _ = io.Copy(dst, src) + _ = src.Close() + _ = dst.Close() + } + _ = os.Remove(covPath) + } + }() + + cli.Print("%s %s\n", dimStyle.Render(i18n.Label("coverage")), i18n.ProgressSubject("run", "tests")) + // Truncate package list if too long for display + displayPkg := pkg + if len(displayPkg) > 60 { + displayPkg = displayPkg[:57] + "..." + } + cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), displayPkg) + cli.Blank() + + // Run tests with coverage + // We need to split pkg into individual arguments if it contains spaces + pkgArgs := strings.Fields(pkg) + cmdArgs := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...) + + goCmd := exec.Command("go", cmdArgs...) + goCmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0") + goCmd.Stdout = os.Stdout + goCmd.Stderr = os.Stderr + + testErr := goCmd.Run() + + // Get coverage percentage + coverCmd := exec.Command("go", "tool", "cover", "-func="+covPath) + covOutput, err := coverCmd.Output() + if err != nil { + if testErr != nil { + return testErr + } + return cli.Wrap(err, i18n.T("i18n.fail.get", "coverage")) + } + + // Parse total coverage from last line + lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n") + var statementCov float64 + if len(lines) > 0 { + lastLine := lines[len(lines)-1] + // Format: "total: (statements) XX.X%" + if strings.Contains(lastLine, "total:") { + parts := strings.Fields(lastLine) + if len(parts) >= 3 { + covStr := strings.TrimSuffix(parts[len(parts)-1], "%") + _, _ = fmt.Sscanf(covStr, "%f", &statementCov) + } + } + } + + // Calculate branch coverage (block coverage) + branchCov, err := calculateBlockCoverage(covPath) + if err != nil { + return cli.Wrap(err, "calculate branch coverage") + } + + // Print coverage summary + cli.Blank() + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(statementCov)) + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov)) + + // Generate HTML if requested + if covHTML || covOpen { + htmlPath := "coverage.html" + htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath) + if err := htmlCmd.Run(); err != nil { + return cli.Wrap(err, i18n.T("i18n.fail.generate", "HTML")) + } + cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("html")), htmlPath) + + if covOpen { + // Open in browser + var openCmd *exec.Cmd + switch { + case exec.Command("which", "open").Run() == nil: + openCmd = exec.Command("open", htmlPath) + case exec.Command("which", "xdg-open").Run() == nil: + openCmd = exec.Command("xdg-open", htmlPath) + default: + cli.Print(" %s\n", dimStyle.Render("Open coverage.html in your browser")) + } + if openCmd != nil { + _ = openCmd.Run() + } + } + } + + // Check thresholds + if covThreshold > 0 && statementCov < covThreshold { + cli.Print("\n%s Statements: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), statementCov, covThreshold) + return errors.New("statement coverage below threshold") + } + if covBranchThreshold > 0 && branchCov < covBranchThreshold { + cli.Print("\n%s Branches: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), branchCov, covBranchThreshold) + return errors.New("branch coverage below threshold") + } + + if testErr != nil { + return testErr + } + + cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass"))) + return nil + }, + } + + covCmd.Flags().StringVar(&covPkg, "pkg", "", "Package to test") + covCmd.Flags().BoolVar(&covHTML, "html", false, "Generate HTML report") + covCmd.Flags().BoolVar(&covOpen, "open", false, "Open HTML report in browser") + covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum statement coverage percentage") + covCmd.Flags().Float64Var(&covBranchThreshold, "branch-threshold", 0, "Minimum branch coverage percentage") + covCmd.Flags().StringVarP(&covOutput, "output", "o", "", "Output file for coverage profile") + + parent.AddCommand(covCmd) +} + +// calculateBlockCoverage parses a Go coverage profile and returns the percentage of basic +// blocks that have a non-zero execution count. Go's coverage profile contains one line per +// basic block, where the last field is the execution count, not explicit branch coverage. +// The resulting block coverage is used here only as a proxy for branch coverage; computing +// true branch coverage would require more detailed control-flow analysis. +func calculateBlockCoverage(path string) (float64, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var totalBlocks, coveredBlocks int + + // Skip the first line (mode: atomic/set/count) + if !scanner.Scan() { + return 0, nil + } + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + + // Last field is the count + count, err := strconv.Atoi(fields[len(fields)-1]) + if err != nil { + continue + } + + totalBlocks++ + if count > 0 { + coveredBlocks++ + } + } + + if err := scanner.Err(); err != nil { + return 0, err + } + + if totalBlocks == 0 { + return 0, nil + } + + return (float64(coveredBlocks) / float64(totalBlocks)) * 100, nil +} + +func findTestPackages(root string) ([]string, error) { + pkgMap := make(map[string]bool) + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") { + dir := filepath.Dir(path) + if !strings.HasPrefix(dir, ".") { + dir = "./" + dir + } + pkgMap[dir] = true + } + return nil + }) + if err != nil { + return nil, err + } + + var pkgs []string + for pkg := range pkgMap { + pkgs = append(pkgs, pkg) + } + return pkgs, nil +} + +func formatCoverage(cov float64) string { + s := fmt.Sprintf("%.1f%%", cov) + if cov >= 80 { + return cli.SuccessStyle.Render(s) + } else if cov >= 50 { + return cli.WarningStyle.Render(s) + } + return cli.ErrorStyle.Render(s) +} diff --git a/cmd/gocmd/cmd_qa.go b/cmd/gocmd/cmd_qa.go new file mode 100644 index 0000000..62d4439 --- /dev/null +++ b/cmd/gocmd/cmd_qa.go @@ -0,0 +1,639 @@ +package gocmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "forge.lthn.ai/core/cli/cmd/qa" + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// QA command flags - comprehensive options for all agents +var ( + qaFix bool + qaChanged bool + qaAll bool + qaSkip string + qaOnly string + qaCoverage bool + qaThreshold float64 + qaBranchThreshold float64 + qaDocblockThreshold float64 + qaJSON bool + qaVerbose bool + qaQuiet bool + qaTimeout time.Duration + qaShort bool + qaRace bool + qaBench bool + qaFailFast bool + qaMod bool + qaCI bool +) + +func addGoQACommand(parent *cli.Command) { + qaCmd := &cli.Command{ + Use: "qa", + Short: "Run QA checks", + Long: `Run comprehensive code quality checks for Go projects. + +Checks available: fmt, vet, lint, test, race, fuzz, vuln, sec, bench, docblock + +Examples: + core go qa # Default: fmt, lint, test + core go qa --fix # Auto-fix formatting and lint issues + core go qa --only=test # Only run tests + core go qa --skip=vuln,sec # Skip vulnerability and security scans + core go qa --coverage --threshold=80 # Require 80% coverage + core go qa --changed # Only check changed files (git-aware) + core go qa --ci # CI mode: strict, coverage, fail-fast + core go qa --race --short # Quick tests with race detection + core go qa --json # Output results as JSON`, + RunE: runGoQA, + } + + // Fix and modification flags (persistent so subcommands inherit them) + qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible") + qaCmd.PersistentFlags().BoolVar(&qaMod, "mod", false, "Run go mod tidy before checks") + + // Scope flags + qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)") + qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)") + qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,fuzz,vuln,sec,bench)") + qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)") + + // Coverage flags + qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting") + qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)") + qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum statement coverage threshold (0-100), fail if below") + qaCmd.PersistentFlags().Float64Var(&qaBranchThreshold, "branch-threshold", 0, "Minimum branch coverage threshold (0-100), fail if below") + qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)") + + // Test flags + qaCmd.PersistentFlags().BoolVar(&qaShort, "short", false, "Run tests with -short flag") + qaCmd.PersistentFlags().BoolVar(&qaRace, "race", false, "Include race detection in tests") + qaCmd.PersistentFlags().BoolVar(&qaBench, "bench", false, "Include benchmarks") + + // Output flags + qaCmd.PersistentFlags().BoolVar(&qaJSON, "json", false, "Output results as JSON") + qaCmd.PersistentFlags().BoolVarP(&qaVerbose, "verbose", "v", false, "Show verbose output") + qaCmd.PersistentFlags().BoolVarP(&qaQuiet, "quiet", "q", false, "Only show errors") + + // Control flags + qaCmd.PersistentFlags().DurationVar(&qaTimeout, "timeout", 10*time.Minute, "Timeout for all checks") + qaCmd.PersistentFlags().BoolVar(&qaFailFast, "fail-fast", false, "Stop on first failure") + qaCmd.PersistentFlags().BoolVar(&qaCI, "ci", false, "CI mode: strict checks, coverage required, fail-fast") + + // Preset subcommands for convenience + qaCmd.AddCommand(&cli.Command{ + Use: "quick", + Short: "Quick QA: fmt, vet, lint (no tests)", + RunE: func(cmd *cli.Command, args []string) error { qaOnly = "fmt,vet,lint"; return runGoQA(cmd, args) }, + }) + + qaCmd.AddCommand(&cli.Command{ + Use: "full", + Short: "Full QA: all checks including race, vuln, sec", + RunE: func(cmd *cli.Command, args []string) error { + qaOnly = "fmt,vet,lint,test,race,vuln,sec" + return runGoQA(cmd, args) + }, + }) + + qaCmd.AddCommand(&cli.Command{ + Use: "pre-commit", + Short: "Pre-commit checks: fmt --fix, lint --fix, test --short", + RunE: func(cmd *cli.Command, args []string) error { + qaFix = true + qaShort = true + qaOnly = "fmt,lint,test" + return runGoQA(cmd, args) + }, + }) + + qaCmd.AddCommand(&cli.Command{ + Use: "pr", + Short: "PR checks: full QA with coverage threshold", + RunE: func(cmd *cli.Command, args []string) error { + qaCoverage = true + if qaThreshold == 0 { + qaThreshold = 50 // Default PR threshold + } + qaOnly = "fmt,vet,lint,test" + return runGoQA(cmd, args) + }, + }) + + parent.AddCommand(qaCmd) +} + +// QAResult holds the result of a QA run for JSON output +type QAResult struct { + Success bool `json:"success"` + Duration string `json:"duration"` + Checks []CheckResult `json:"checks"` + Coverage *float64 `json:"coverage,omitempty"` + BranchCoverage *float64 `json:"branch_coverage,omitempty"` + Threshold *float64 `json:"threshold,omitempty"` + BranchThreshold *float64 `json:"branch_threshold,omitempty"` +} + +// CheckResult holds the result of a single check +type CheckResult struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Duration string `json:"duration"` + Error string `json:"error,omitempty"` + Output string `json:"output,omitempty"` + FixHint string `json:"fix_hint,omitempty"` +} + +func runGoQA(cmd *cli.Command, args []string) error { + // Apply CI mode defaults + if qaCI { + qaCoverage = true + qaFailFast = true + if qaThreshold == 0 { + qaThreshold = 50 + } + } + + cwd, err := os.Getwd() + if err != nil { + return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory")) + } + + // Detect if this is a Go project + if _, err := os.Stat("go.mod"); os.IsNotExist(err) { + return cli.Err("not a Go project (no go.mod found)") + } + + // Determine which checks to run + checkNames := determineChecks() + + if !qaJSON && !qaQuiet { + cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA")) + } + + // Run go mod tidy if requested + if qaMod { + if !qaQuiet { + cli.Print("%s %s\n", cli.DimStyle.Render("→"), "Running go mod tidy...") + } + modCmd := exec.Command("go", "mod", "tidy") + modCmd.Dir = cwd + if err := modCmd.Run(); err != nil { + return cli.Wrap(err, "go mod tidy failed") + } + } + + ctx, cancel := context.WithTimeout(context.Background(), qaTimeout) + defer cancel() + + startTime := time.Now() + checks := buildChecks(checkNames) + results := make([]CheckResult, 0, len(checks)) + passed := 0 + failed := 0 + + for _, check := range checks { + checkStart := time.Now() + + if !qaJSON && !qaQuiet { + cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name)) + } + + output, err := runCheckCapture(ctx, cwd, check) + checkDuration := time.Since(checkStart) + + result := CheckResult{ + Name: check.Name, + Duration: checkDuration.Round(time.Millisecond).String(), + } + + if err != nil { + result.Passed = false + result.Error = err.Error() + if qaVerbose { + result.Output = output + } + result.FixHint = fixHintFor(check.Name, output) + failed++ + + if !qaJSON && !qaQuiet { + cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error()) + if qaVerbose && output != "" { + cli.Text(output) + } + if result.FixHint != "" { + cli.Hint("fix", result.FixHint) + } + } + + if qaFailFast { + results = append(results, result) + break + } + } else { + result.Passed = true + if qaVerbose { + result.Output = output + } + passed++ + + if !qaJSON && !qaQuiet { + cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass")) + } + } + + results = append(results, result) + } + + // Run coverage if requested + var coverageVal *float64 + var branchVal *float64 + if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) { + cov, branch, err := runCoverage(ctx, cwd) + if err == nil { + coverageVal = &cov + branchVal = &branch + if !qaJSON && !qaQuiet { + cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Statement Coverage:"), cov) + cli.Print("%s %.1f%%\n", cli.DimStyle.Render("Branch Coverage:"), branch) + } + if qaThreshold > 0 && cov < qaThreshold { + failed++ + if !qaJSON && !qaQuiet { + cli.Print(" %s Statement coverage %.1f%% below threshold %.1f%%\n", + cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) + } + } + if qaBranchThreshold > 0 && branch < qaBranchThreshold { + failed++ + if !qaJSON && !qaQuiet { + cli.Print(" %s Branch coverage %.1f%% below threshold %.1f%%\n", + cli.ErrorStyle.Render(cli.Glyph(":cross:")), branch, qaBranchThreshold) + } + } + + if failed > 0 && !qaJSON && !qaQuiet { + cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") + } + } + } + + duration := time.Since(startTime).Round(time.Millisecond) + + // JSON output + if qaJSON { + qaResult := QAResult{ + Success: failed == 0, + Duration: duration.String(), + Checks: results, + Coverage: coverageVal, + BranchCoverage: branchVal, + } + if qaThreshold > 0 { + qaResult.Threshold = &qaThreshold + } + if qaBranchThreshold > 0 { + qaResult.BranchThreshold = &qaBranchThreshold + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(qaResult) + } + + // Summary + if !qaQuiet { + cli.Blank() + if failed > 0 { + cli.Print("%s %s, %s (%s)\n", + cli.ErrorStyle.Render(cli.Glyph(":cross:")), + i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"), + i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"), + duration) + } else { + cli.Print("%s %s (%s)\n", + cli.SuccessStyle.Render(cli.Glyph(":check:")), + i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"), + duration) + } + } + + if failed > 0 { + return cli.Err("QA checks failed: %d passed, %d failed", passed, failed) + } + return nil +} + +func determineChecks() []string { + // If --only is specified, use those + if qaOnly != "" { + return strings.Split(qaOnly, ",") + } + + // Default checks + checks := []string{"fmt", "lint", "test", "fuzz", "docblock"} + + // Add race if requested + if qaRace { + // Replace test with race (which includes test) + for i, c := range checks { + if c == "test" { + checks[i] = "race" + break + } + } + } + + // Add bench if requested + if qaBench { + checks = append(checks, "bench") + } + + // Remove skipped checks + if qaSkip != "" { + skipMap := make(map[string]bool) + for _, s := range strings.Split(qaSkip, ",") { + skipMap[strings.TrimSpace(s)] = true + } + filtered := make([]string, 0, len(checks)) + for _, c := range checks { + if !skipMap[c] { + filtered = append(filtered, c) + } + } + checks = filtered + } + + return checks +} + +// QACheck represents a single QA check. +type QACheck struct { + Name string + Command string + Args []string +} + +func buildChecks(names []string) []QACheck { + var checks []QACheck + for _, name := range names { + name = strings.TrimSpace(name) + check := buildCheck(name) + if check.Command != "" { + checks = append(checks, check) + } + } + return checks +} + +func buildCheck(name string) QACheck { + switch name { + case "fmt", "format": + args := []string{"-l", "."} + if qaFix { + args = []string{"-w", "."} + } + return QACheck{Name: "format", Command: "gofmt", Args: args} + + case "vet": + return QACheck{Name: "vet", Command: "go", Args: []string{"vet", "./..."}} + + case "lint": + args := []string{"run"} + if qaFix { + args = append(args, "--fix") + } + if qaChanged && !qaAll { + args = append(args, "--new-from-rev=HEAD") + } + args = append(args, "./...") + return QACheck{Name: "lint", Command: "golangci-lint", Args: args} + + case "test": + args := []string{"test"} + if qaShort { + args = append(args, "-short") + } + if qaVerbose { + args = append(args, "-v") + } + args = append(args, "./...") + return QACheck{Name: "test", Command: "go", Args: args} + + case "race": + args := []string{"test", "-race"} + if qaShort { + args = append(args, "-short") + } + if qaVerbose { + args = append(args, "-v") + } + args = append(args, "./...") + return QACheck{Name: "race", Command: "go", Args: args} + + case "bench": + args := []string{"test", "-bench=.", "-benchmem", "-run=^$"} + args = append(args, "./...") + return QACheck{Name: "bench", Command: "go", Args: args} + + case "vuln": + return QACheck{Name: "vuln", Command: "govulncheck", Args: []string{"./..."}} + + case "sec": + return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}} + + case "fuzz": + return QACheck{Name: "fuzz", Command: "_internal_"} + + case "docblock": + // Special internal check - handled separately + return QACheck{Name: "docblock", Command: "_internal_"} + + default: + return QACheck{} + } +} + +// fixHintFor returns an actionable fix instruction for a given check failure. +func fixHintFor(checkName, output string) string { + switch checkName { + case "format", "fmt": + return "Run 'core go qa fmt --fix' to auto-format." + case "vet": + return "Fix the issues reported by go vet — typically genuine bugs." + case "lint": + return "Run 'core go qa lint --fix' for auto-fixable issues." + case "test": + if name := extractFailingTest(output); name != "" { + return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name) + } + return "Run 'go test -run -v ./path/' to debug." + case "race": + return "Data race detected. Add mutex, channel, or atomic to synchronise shared state." + case "bench": + return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce." + case "vuln": + return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'." + case "sec": + return "Review gosec findings. Common fixes: validate inputs, parameterised queries." + case "fuzz": + return "Add a regression test for the crashing input in testdata/fuzz//." + case "docblock": + return "Add doc comments to exported symbols: '// Name does X.' before each declaration." + default: + return "" + } +} + +var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`) + +// extractFailingTest parses the first failing test name from go test output. +func extractFailingTest(output string) string { + if m := failTestRe.FindStringSubmatch(output); len(m) > 1 { + return m[1] + } + return "" +} + +func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) { + // Handle internal checks + if check.Command == "_internal_" { + return runInternalCheck(check) + } + + // Check if command exists + if _, err := exec.LookPath(check.Command); err != nil { + return "", cli.Err("%s: not installed", check.Command) + } + + cmd := exec.CommandContext(ctx, check.Command, check.Args...) + cmd.Dir = dir + + // For gofmt -l, capture output to check if files need formatting + if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" { + output, err := cmd.Output() + if err != nil { + return string(output), err + } + if len(output) > 0 { + // Show files that need formatting + if !qaQuiet && !qaJSON { + cli.Text(string(output)) + } + return string(output), cli.Err("files need formatting (use --fix)") + } + return "", nil + } + + // For other commands, stream or capture based on quiet mode + if qaQuiet || qaJSON { + output, err := cmd.CombinedOutput() + return string(output), err + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return "", cmd.Run() +} + +func runCoverage(ctx context.Context, dir string) (float64, float64, error) { + // Create temp file for coverage data + covFile, err := os.CreateTemp("", "coverage-*.out") + if err != nil { + return 0, 0, err + } + covPath := covFile.Name() + _ = covFile.Close() + defer os.Remove(covPath) + + args := []string{"test", "-cover", "-covermode=atomic", "-coverprofile=" + covPath} + if qaShort { + args = append(args, "-short") + } + args = append(args, "./...") + + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = dir + if !qaQuiet && !qaJSON { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + if err := cmd.Run(); err != nil { + return 0, 0, err + } + + // Parse statement coverage + coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+covPath) + output, err := coverCmd.Output() + if err != nil { + return 0, 0, err + } + + // Parse last line for total coverage + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var statementPct float64 + if len(lines) > 0 { + lastLine := lines[len(lines)-1] + fields := strings.Fields(lastLine) + if len(fields) >= 3 { + // Parse percentage (e.g., "45.6%") + pctStr := strings.TrimSuffix(fields[len(fields)-1], "%") + _, _ = fmt.Sscanf(pctStr, "%f", &statementPct) + } + } + + // Parse branch coverage + branchPct, err := calculateBlockCoverage(covPath) + if err != nil { + return statementPct, 0, err + } + + return statementPct, branchPct, nil +} + +// runInternalCheck runs internal Go-based checks (not external commands). +func runInternalCheck(check QACheck) (string, error) { + switch check.Name { + case "fuzz": + // Short burst fuzz in QA (3s per target) + duration := 3 * time.Second + if qaTimeout > 0 && qaTimeout < 30*time.Second { + duration = 2 * time.Second + } + return "", runGoFuzz(duration, "", "", qaVerbose) + + case "docblock": + result, err := qa.CheckDocblockCoverage([]string{"./..."}) + if err != nil { + return "", err + } + result.Threshold = qaDocblockThreshold + result.Passed = result.Coverage >= qaDocblockThreshold + + if !result.Passed { + var output strings.Builder + output.WriteString(fmt.Sprintf("Docblock coverage: %.1f%% (threshold: %.1f%%)\n", + result.Coverage, qaDocblockThreshold)) + for _, m := range result.Missing { + output.WriteString(fmt.Sprintf("%s:%d\n", m.File, m.Line)) + } + return output.String(), cli.Err("docblock coverage %.1f%% below threshold %.1f%%", + result.Coverage, qaDocblockThreshold) + } + return fmt.Sprintf("Docblock coverage: %.1f%%", result.Coverage), nil + + default: + return "", cli.Err("unknown internal check: %s", check.Name) + } +} diff --git a/cmd/gocmd/cmd_tools.go b/cmd/gocmd/cmd_tools.go new file mode 100644 index 0000000..0283062 --- /dev/null +++ b/cmd/gocmd/cmd_tools.go @@ -0,0 +1,236 @@ +package gocmd + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" +) + +var ( + installVerbose bool + installNoCgo bool +) + +func addGoInstallCommand(parent *cli.Command) { + installCmd := &cli.Command{ + Use: "install [path]", + Short: "Install Go binary", + Long: "Install Go binary to $GOPATH/bin", + RunE: func(cmd *cli.Command, args []string) error { + // Get install path from args or default to current dir + installPath := "./..." + if len(args) > 0 { + installPath = args[0] + } + + // Detect if we're in a module with cmd/ subdirectories or a root main.go + if installPath == "./..." { + if _, err := os.Stat("core.go"); err == nil { + installPath = "." + } else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 { + installPath = "./cmd/..." + } else if _, err := os.Stat("main.go"); err == nil { + installPath = "." + } + } + + cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.Progress("install")) + cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), installPath) + if installNoCgo { + cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("cgo")), "disabled") + } + + cmdArgs := []string{"install"} + if installVerbose { + cmdArgs = append(cmdArgs, "-v") + } + cmdArgs = append(cmdArgs, installPath) + + execCmd := exec.Command("go", cmdArgs...) + if installNoCgo { + execCmd.Env = append(os.Environ(), "CGO_ENABLED=0") + } + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + + if err := execCmd.Run(); err != nil { + cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.fail.install", "binary"))) + return err + } + + // Show where it was installed + gopath := os.Getenv("GOPATH") + if gopath == "" { + home, _ := os.UserHomeDir() + gopath = filepath.Join(home, "go") + } + binDir := filepath.Join(gopath, "bin") + + cli.Print("\n%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), binDir) + return nil + }, + } + + installCmd.Flags().BoolVarP(&installVerbose, "verbose", "v", false, "Verbose output") + installCmd.Flags().BoolVar(&installNoCgo, "no-cgo", false, "Disable CGO") + + parent.AddCommand(installCmd) +} + +func addGoModCommand(parent *cli.Command) { + modCmd := &cli.Command{ + Use: "mod", + Short: "Module management", + Long: "Go module management commands", + } + + // tidy + tidyCmd := &cli.Command{ + Use: "tidy", + Short: "Run go mod tidy", + RunE: func(cmd *cli.Command, args []string) error { + execCmd := exec.Command("go", "mod", "tidy") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + }, + } + + // download + downloadCmd := &cli.Command{ + Use: "download", + Short: "Download module dependencies", + RunE: func(cmd *cli.Command, args []string) error { + execCmd := exec.Command("go", "mod", "download") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + }, + } + + // verify + verifyCmd := &cli.Command{ + Use: "verify", + Short: "Verify module checksums", + RunE: func(cmd *cli.Command, args []string) error { + execCmd := exec.Command("go", "mod", "verify") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + }, + } + + // graph + graphCmd := &cli.Command{ + Use: "graph", + Short: "Print module dependency graph", + RunE: func(cmd *cli.Command, args []string) error { + execCmd := exec.Command("go", "mod", "graph") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + }, + } + + modCmd.AddCommand(tidyCmd) + modCmd.AddCommand(downloadCmd) + modCmd.AddCommand(verifyCmd) + modCmd.AddCommand(graphCmd) + parent.AddCommand(modCmd) +} + +func addGoWorkCommand(parent *cli.Command) { + workCmd := &cli.Command{ + Use: "work", + Short: "Workspace management", + Long: "Go workspace management commands", + } + + // sync + syncCmd := &cli.Command{ + Use: "sync", + Short: "Sync workspace modules", + RunE: func(cmd *cli.Command, args []string) error { + execCmd := exec.Command("go", "work", "sync") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + }, + } + + // init + initCmd := &cli.Command{ + Use: "init", + Short: "Initialise a new workspace", + RunE: func(cmd *cli.Command, args []string) error { + execCmd := exec.Command("go", "work", "init") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + if err := execCmd.Run(); err != nil { + return err + } + // Auto-add current module if go.mod exists + if _, err := os.Stat("go.mod"); err == nil { + execCmd = exec.Command("go", "work", "use", ".") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + } + return nil + }, + } + + // use + useCmd := &cli.Command{ + Use: "use [modules...]", + Short: "Add modules to workspace", + RunE: func(cmd *cli.Command, args []string) error { + if len(args) == 0 { + // Auto-detect modules + modules := findGoModules(".") + if len(modules) == 0 { + return errors.New("no Go modules found") + } + for _, mod := range modules { + execCmd := exec.Command("go", "work", "use", mod) + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + if err := execCmd.Run(); err != nil { + return err + } + cli.Print("%s %s\n", successStyle.Render(i18n.T("i18n.done.add")), mod) + } + return nil + } + + cmdArgs := append([]string{"work", "use"}, args...) + execCmd := exec.Command("go", cmdArgs...) + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() + }, + } + + workCmd.AddCommand(syncCmd) + workCmd.AddCommand(initCmd) + workCmd.AddCommand(useCmd) + parent.AddCommand(workCmd) +} + +func findGoModules(root string) []string { + var modules []string + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.Name() == "go.mod" && path != "go.mod" { + modules = append(modules, filepath.Dir(path)) + } + return nil + }) + return modules +} diff --git a/cmd/gocmd/coverage_test.go b/cmd/gocmd/coverage_test.go new file mode 100644 index 0000000..53cc346 --- /dev/null +++ b/cmd/gocmd/coverage_test.go @@ -0,0 +1,229 @@ +package gocmd + +import ( + "os" + "testing" + + "forge.lthn.ai/core/go/pkg/cli" + "github.com/stretchr/testify/assert" +) + +func TestCalculateBlockCoverage(t *testing.T) { + // Create a dummy coverage profile + content := `mode: set +forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 1 +forge.lthn.ai/core/go/pkg/foo.go:5.6,7.8 2 0 +forge.lthn.ai/core/go/pkg/bar.go:10.1,12.20 10 5 +` + tmpfile, err := os.CreateTemp("", "test-coverage-*.out") + assert.NoError(t, err) + defer os.Remove(tmpfile.Name()) + + _, err = tmpfile.Write([]byte(content)) + assert.NoError(t, err) + err = tmpfile.Close() + assert.NoError(t, err) + + // Test calculation + // 3 blocks total, 2 covered (count > 0) + // Expect (2/3) * 100 = 66.666... + pct, err := calculateBlockCoverage(tmpfile.Name()) + assert.NoError(t, err) + assert.InDelta(t, 66.67, pct, 0.01) + + // Test empty file (only header) + contentEmpty := "mode: atomic\n" + tmpfileEmpty, _ := os.CreateTemp("", "test-coverage-empty-*.out") + defer os.Remove(tmpfileEmpty.Name()) + tmpfileEmpty.Write([]byte(contentEmpty)) + tmpfileEmpty.Close() + + pct, err = calculateBlockCoverage(tmpfileEmpty.Name()) + assert.NoError(t, err) + assert.Equal(t, 0.0, pct) + + // Test non-existent file + pct, err = calculateBlockCoverage("non-existent-file") + assert.Error(t, err) + assert.Equal(t, 0.0, pct) + + // Test malformed file + contentMalformed := `mode: set +forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 +forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 notanumber +` + tmpfileMalformed, _ := os.CreateTemp("", "test-coverage-malformed-*.out") + defer os.Remove(tmpfileMalformed.Name()) + tmpfileMalformed.Write([]byte(contentMalformed)) + tmpfileMalformed.Close() + + pct, err = calculateBlockCoverage(tmpfileMalformed.Name()) + assert.NoError(t, err) + assert.Equal(t, 0.0, pct) + + // Test malformed file - missing fields + contentMalformed2 := `mode: set +forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 +` + tmpfileMalformed2, _ := os.CreateTemp("", "test-coverage-malformed2-*.out") + defer os.Remove(tmpfileMalformed2.Name()) + tmpfileMalformed2.Write([]byte(contentMalformed2)) + tmpfileMalformed2.Close() + + pct, err = calculateBlockCoverage(tmpfileMalformed2.Name()) + assert.NoError(t, err) + assert.Equal(t, 0.0, pct) + + // Test completely empty file + tmpfileEmpty2, _ := os.CreateTemp("", "test-coverage-empty2-*.out") + defer os.Remove(tmpfileEmpty2.Name()) + tmpfileEmpty2.Close() + pct, err = calculateBlockCoverage(tmpfileEmpty2.Name()) + assert.NoError(t, err) + assert.Equal(t, 0.0, pct) +} + +func TestParseOverallCoverage(t *testing.T) { + output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements +ok forge.lthn.ai/core/go/pkg/bar 0.200s coverage: 100.0% of statements +` + pct := parseOverallCoverage(output) + assert.Equal(t, 75.0, pct) + + outputNoCov := "ok forge.lthn.ai/core/go/pkg/foo 0.100s" + pct = parseOverallCoverage(outputNoCov) + assert.Equal(t, 0.0, pct) +} + +func TestFormatCoverage(t *testing.T) { + assert.Contains(t, formatCoverage(85.0), "85.0%") + assert.Contains(t, formatCoverage(65.0), "65.0%") + assert.Contains(t, formatCoverage(25.0), "25.0%") +} + +func TestAddGoCovCommand(t *testing.T) { + cmd := &cli.Command{Use: "test"} + addGoCovCommand(cmd) + assert.True(t, cmd.HasSubCommands()) + sub := cmd.Commands()[0] + assert.Equal(t, "cov", sub.Name()) +} + +func TestAddGoQACommand(t *testing.T) { + cmd := &cli.Command{Use: "test"} + addGoQACommand(cmd) + assert.True(t, cmd.HasSubCommands()) + sub := cmd.Commands()[0] + assert.Equal(t, "qa", sub.Name()) +} + +func TestDetermineChecks(t *testing.T) { + // Default checks + qaOnly = "" + qaSkip = "" + qaRace = false + qaBench = false + checks := determineChecks() + assert.Contains(t, checks, "fmt") + assert.Contains(t, checks, "test") + + // Only + qaOnly = "fmt,lint" + checks = determineChecks() + assert.Equal(t, []string{"fmt", "lint"}, checks) + + // Skip + qaOnly = "" + qaSkip = "fmt,lint" + checks = determineChecks() + assert.NotContains(t, checks, "fmt") + assert.NotContains(t, checks, "lint") + assert.Contains(t, checks, "test") + + // Race + qaSkip = "" + qaRace = true + checks = determineChecks() + assert.Contains(t, checks, "race") + assert.NotContains(t, checks, "test") + + // Reset + qaRace = false +} + +func TestBuildCheck(t *testing.T) { + qaFix = false + c := buildCheck("fmt") + assert.Equal(t, "format", c.Name) + assert.Equal(t, []string{"-l", "."}, c.Args) + + qaFix = true + c = buildCheck("fmt") + assert.Equal(t, []string{"-w", "."}, c.Args) + + c = buildCheck("vet") + assert.Equal(t, "vet", c.Name) + + c = buildCheck("lint") + assert.Equal(t, "lint", c.Name) + + c = buildCheck("test") + assert.Equal(t, "test", c.Name) + + c = buildCheck("race") + assert.Equal(t, "race", c.Name) + + c = buildCheck("bench") + assert.Equal(t, "bench", c.Name) + + c = buildCheck("vuln") + assert.Equal(t, "vuln", c.Name) + + c = buildCheck("sec") + assert.Equal(t, "sec", c.Name) + + c = buildCheck("fuzz") + assert.Equal(t, "fuzz", c.Name) + + c = buildCheck("docblock") + assert.Equal(t, "docblock", c.Name) + + c = buildCheck("unknown") + assert.Equal(t, "", c.Name) +} + +func TestBuildChecks(t *testing.T) { + checks := buildChecks([]string{"fmt", "vet", "unknown"}) + assert.Equal(t, 2, len(checks)) + assert.Equal(t, "format", checks[0].Name) + assert.Equal(t, "vet", checks[1].Name) +} + +func TestFixHintFor(t *testing.T) { + assert.Contains(t, fixHintFor("format", ""), "core go qa fmt --fix") + assert.Contains(t, fixHintFor("vet", ""), "go vet") + assert.Contains(t, fixHintFor("lint", ""), "core go qa lint --fix") + assert.Contains(t, fixHintFor("test", "--- FAIL: TestFoo"), "TestFoo") + assert.Contains(t, fixHintFor("race", ""), "Data race") + assert.Contains(t, fixHintFor("bench", ""), "Benchmark regression") + assert.Contains(t, fixHintFor("vuln", ""), "govulncheck") + assert.Contains(t, fixHintFor("sec", ""), "gosec") + assert.Contains(t, fixHintFor("fuzz", ""), "crashing input") + assert.Contains(t, fixHintFor("docblock", ""), "doc comments") + assert.Equal(t, "", fixHintFor("unknown", "")) +} + +func TestRunGoQA_NoGoMod(t *testing.T) { + // runGoQA should fail if go.mod is not present in CWD + // We run it in a temp dir without go.mod + tmpDir, _ := os.MkdirTemp("", "test-qa-*") + defer os.RemoveAll(tmpDir) + cwd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(cwd) + + cmd := &cli.Command{Use: "qa"} + err := runGoQA(cmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no go.mod found") +}