feat: absorb gocmd from core/go
Move Go dev commands (test, fmt, lint, fuzz, qa, tools) into cli. core/cli is now the sole producer of the 'core' binary. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
f7d72c843b
commit
7f555c6f8a
9 changed files with 1923 additions and 0 deletions
15
cmd/gocmd/cmd_commands.go
Normal file
15
cmd/gocmd/cmd_commands.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// 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
|
||||||
177
cmd/gocmd/cmd_format.go
Normal file
177
cmd/gocmd/cmd_format.go
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
package gocmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-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)
|
||||||
|
}
|
||||||
169
cmd/gocmd/cmd_fuzz.go
Normal file
169
cmd/gocmd/cmd_fuzz.go
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
package gocmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-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
|
||||||
|
}
|
||||||
36
cmd/gocmd/cmd_go.go
Normal file
36
cmd/gocmd/cmd_go.go
Normal file
|
|
@ -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/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-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)
|
||||||
|
}
|
||||||
430
cmd/gocmd/cmd_gotest.go
Normal file
430
cmd/gocmd/cmd_gotest.go
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
package gocmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-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)
|
||||||
|
}
|
||||||
629
cmd/gocmd/cmd_qa.go
Normal file
629
cmd/gocmd/cmd_qa.go
Normal file
|
|
@ -0,0 +1,629 @@
|
||||||
|
package gocmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-devops/cmd/qa"
|
||||||
|
"forge.lthn.ai/core/go-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 <TestName> -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/<Target>/."
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if result.Coverage < qaDocblockThreshold {
|
||||||
|
return "", cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, qaDocblockThreshold)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("docblock coverage: %.1f%% (%d/%d)", result.Coverage, result.Documented, result.Total), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", cli.Err("unknown internal check: %s", check.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
236
cmd/gocmd/cmd_tools.go
Normal file
236
cmd/gocmd/cmd_tools.go
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
package gocmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-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
|
||||||
|
}
|
||||||
229
cmd/gocmd/coverage_test.go
Normal file
229
cmd/gocmd/coverage_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
package gocmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/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")
|
||||||
|
}
|
||||||
2
main.go
2
main.go
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/cli/cmd/config"
|
"forge.lthn.ai/core/cli/cmd/config"
|
||||||
"forge.lthn.ai/core/cli/cmd/doctor"
|
"forge.lthn.ai/core/cli/cmd/doctor"
|
||||||
|
"forge.lthn.ai/core/cli/cmd/gocmd"
|
||||||
"forge.lthn.ai/core/cli/cmd/help"
|
"forge.lthn.ai/core/cli/cmd/help"
|
||||||
"forge.lthn.ai/core/cli/cmd/module"
|
"forge.lthn.ai/core/cli/cmd/module"
|
||||||
"forge.lthn.ai/core/cli/cmd/pkgcmd"
|
"forge.lthn.ai/core/cli/cmd/pkgcmd"
|
||||||
|
|
@ -20,5 +21,6 @@ func main() {
|
||||||
cli.WithCommands("pkg", pkgcmd.AddPkgCommands),
|
cli.WithCommands("pkg", pkgcmd.AddPkgCommands),
|
||||||
cli.WithCommands("plugin", plugin.AddPluginCommands),
|
cli.WithCommands("plugin", plugin.AddPluginCommands),
|
||||||
cli.WithCommands("session", session.AddSessionCommands),
|
cli.WithCommands("session", session.AddSessionCommands),
|
||||||
|
cli.WithCommands("go", gocmd.AddGoCommands),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue