diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c3bacfc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +# Normalize all text files to LF +* text=auto eol=lf + +# Ensure shell scripts use LF +*.sh text eol=lf + +# Ensure Go files use LF +*.go text eol=lf + +# Ensure JSON/YAML use LF +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf + +# Binary files +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary diff --git a/internal/tools/i18n-validate/main.go b/internal/tools/i18n-validate/main.go index 817759e..5e0d942 100644 --- a/internal/tools/i18n-validate/main.go +++ b/internal/tools/i18n-validate/main.go @@ -39,11 +39,11 @@ type KeyUsage struct { // ValidationResult holds the results of validation. type ValidationResult struct { - TotalKeys int - ValidKeys int - MissingKeys []KeyUsage - IntentKeys int - MessageKeys int + TotalKeys int + ValidKeys int + MissingKeys []KeyUsage + IntentKeys int + MessageKeys int } func main() { @@ -369,7 +369,8 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage { } // Check for T(), C(), i18n.T(), i18n.C() - if funcName == "T" || funcName == "i18n.T" || funcName == "_" || funcName == "i18n._" { + switch funcName { + case "T", "i18n.T", "_", "i18n._": if key := extractStringArg(call, 0); key != "" { pos := fset.Position(call.Pos()) usages = append(usages, KeyUsage{ @@ -379,7 +380,7 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage { Function: "T", }) } - } else if funcName == "C" || funcName == "i18n.C" { + case "C", "i18n.C": if key := extractStringArg(call, 0); key != "" { pos := fset.Position(call.Pos()) usages = append(usages, KeyUsage{ @@ -389,7 +390,7 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage { Function: "C", }) } - } else if funcName == "I" || funcName == "i18n.I" { + case "I", "i18n.I": if key := extractStringArg(call, 0); key != "" { pos := fset.Position(call.Pos()) usages = append(usages, KeyUsage{ diff --git a/internal/variants/full.go b/internal/variants/full.go index 30542eb..e7c7cb8 100644 --- a/internal/variants/full.go +++ b/internal/variants/full.go @@ -18,6 +18,7 @@ // - setup: Repository cloning and setup // - doctor: Environment health checks // - test: Test runner with coverage +// - qa: Quality assurance workflows package variants @@ -29,9 +30,11 @@ import ( _ "github.com/host-uk/core/pkg/dev" _ "github.com/host-uk/core/pkg/docs" _ "github.com/host-uk/core/pkg/doctor" + _ "github.com/host-uk/core/pkg/gitcmd" _ "github.com/host-uk/core/pkg/go" _ "github.com/host-uk/core/pkg/php" _ "github.com/host-uk/core/pkg/pkgcmd" + _ "github.com/host-uk/core/pkg/qa" _ "github.com/host-uk/core/pkg/sdk" _ "github.com/host-uk/core/pkg/security" _ "github.com/host-uk/core/pkg/setup" diff --git a/pkg/agentic/client.go b/pkg/agentic/client.go index c2213ca..72675a1 100644 --- a/pkg/agentic/client.go +++ b/pkg/agentic/client.go @@ -295,12 +295,6 @@ func (c *Client) checkResponse(resp *http.Response) error { } } -// mustReadAll reads all bytes from a reader, returning empty slice on error. -func mustReadAll(r io.Reader) []byte { - data, _ := io.ReadAll(r) - return data -} - // Ping tests the connection to the API server. func (c *Client) Ping(ctx context.Context) error { const op = "agentic.Client.Ping" diff --git a/pkg/ai/cmd_ai.go b/pkg/ai/cmd_ai.go index 136006b..eddbae5 100644 --- a/pkg/ai/cmd_ai.go +++ b/pkg/ai/cmd_ai.go @@ -28,8 +28,8 @@ var ( // Task-specific styles (aliases to shared where possible) var ( - taskIDStyle = cli.TitleStyle // Bold + blue - taskTitleStyle = cli.ValueStyle // Light gray + taskIDStyle = cli.TitleStyle // Bold + blue + taskTitleStyle = cli.ValueStyle // Light gray taskLabelStyle = cli.NewStyle().Foreground(cli.ColourViolet500) // Violet for labels ) diff --git a/pkg/ai/cmd_tasks.go b/pkg/ai/cmd_tasks.go index db82111..3eb665e 100644 --- a/pkg/ai/cmd_tasks.go +++ b/pkg/ai/cmd_tasks.go @@ -286,4 +286,4 @@ func formatTaskStatus(s agentic.TaskStatus) string { default: return dimStyle.Render(string(s)) } -} \ No newline at end of file +} diff --git a/pkg/build/build.go b/pkg/build/build.go index 947d589..c463d5d 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -10,13 +10,21 @@ import ( // ProjectType represents a detected project type. type ProjectType string +// Project type constants for build detection. const ( - ProjectTypeGo ProjectType = "go" - ProjectTypeWails ProjectType = "wails" - ProjectTypeNode ProjectType = "node" - ProjectTypePHP ProjectType = "php" - ProjectTypeDocker ProjectType = "docker" + // ProjectTypeGo indicates a standard Go project with go.mod. + ProjectTypeGo ProjectType = "go" + // ProjectTypeWails indicates a Wails desktop application. + ProjectTypeWails ProjectType = "wails" + // ProjectTypeNode indicates a Node.js project with package.json. + ProjectTypeNode ProjectType = "node" + // ProjectTypePHP indicates a PHP/Laravel project with composer.json. + ProjectTypePHP ProjectType = "php" + // ProjectTypeDocker indicates a Docker-based project with Dockerfile. + ProjectTypeDocker ProjectType = "docker" + // ProjectTypeLinuxKit indicates a LinuxKit VM configuration. ProjectTypeLinuxKit ProjectType = "linuxkit" + // ProjectTypeTaskfile indicates a project using Taskfile automation. ProjectTypeTaskfile ProjectType = "taskfile" ) diff --git a/pkg/build/buildcmd/cmd_build.go b/pkg/build/buildcmd/cmd_build.go index 50f774c..e9bb65a 100644 --- a/pkg/build/buildcmd/cmd_build.go +++ b/pkg/build/buildcmd/cmd_build.go @@ -33,6 +33,7 @@ var ( outputDir string doArchive bool doChecksum bool + verbose bool // Docker/LinuxKit specific flags configPath string @@ -62,7 +63,7 @@ var buildCmd = &cobra.Command{ Short: i18n.T("cmd.build.short"), Long: i18n.T("cmd.build.long"), RunE: func(cmd *cobra.Command, args []string) error { - return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize) + return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize, verbose) }, } @@ -101,6 +102,7 @@ func initBuildFlags() { // Main build command flags buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.build.flag.type")) buildCmd.Flags().BoolVar(&ciMode, "ci", false, i18n.T("cmd.build.flag.ci")) + buildCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, i18n.T("common.flag.verbose")) buildCmd.Flags().StringVar(&targets, "targets", "", i18n.T("cmd.build.flag.targets")) buildCmd.Flags().StringVar(&outputDir, "output", "", i18n.T("cmd.build.flag.output")) buildCmd.Flags().BoolVar(&doArchive, "archive", true, i18n.T("cmd.build.flag.archive")) diff --git a/pkg/build/buildcmd/cmd_project.go b/pkg/build/buildcmd/cmd_project.go index 1b7109a..da9abbd 100644 --- a/pkg/build/buildcmd/cmd_project.go +++ b/pkg/build/buildcmd/cmd_project.go @@ -21,7 +21,7 @@ import ( ) // runProjectBuild handles the main `core build` command with auto-detection. -func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error { +func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool, verbose bool) error { // Get current working directory as project root projectDir, err := os.Getwd() if err != nil { @@ -80,8 +80,8 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi binaryName = filepath.Base(projectDir) } - // Print build info (unless CI mode) - if !ciMode { + // Print build info (verbose mode only) + if verbose && !ciMode { fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.build")), i18n.T("cmd.build.building_project")) fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.type"), buildTargetStyle.Render(string(projectType))) fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.output"), buildTargetStyle.Render(outputDir)) @@ -120,12 +120,12 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi artifacts, err := builder.Build(ctx, cfg, buildTargets) if err != nil { if !ciMode { - fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("common.error.failed", map[string]any{"Action": "build"}), err) + fmt.Printf("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err) } return err } - if !ciMode { + if verbose && !ciMode { fmt.Printf("%s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)})) fmt.Println() for _, artifact := range artifacts { @@ -151,7 +151,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi } if signCfg.Enabled && runtime.GOOS == "darwin" { - if !ciMode { + if verbose && !ciMode { fmt.Println() fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.sign")), i18n.T("cmd.build.signing_binaries")) } @@ -182,7 +182,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi // Archive artifacts if enabled var archivedArtifacts []build.Artifact if doArchive && len(artifacts) > 0 { - if !ciMode { + if verbose && !ciMode { fmt.Println() fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.archive")), i18n.T("cmd.build.creating_archives")) } @@ -195,7 +195,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi return err } - if !ciMode { + if verbose && !ciMode { for _, artifact := range archivedArtifacts { relPath, err := filepath.Rel(projectDir, artifact.Path) if err != nil { @@ -213,19 +213,19 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi // Compute checksums if enabled var checksummedArtifacts []build.Artifact if doChecksum && len(archivedArtifacts) > 0 { - checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, archivedArtifacts, signCfg, ciMode) + checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, archivedArtifacts, signCfg, ciMode, verbose) if err != nil { return err } } else if doChecksum && len(artifacts) > 0 && !doArchive { // Checksum raw binaries if archiving is disabled - checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, artifacts, signCfg, ciMode) + checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, artifacts, signCfg, ciMode, verbose) if err != nil { return err } } - // Output results for CI mode + // Output results if ciMode { // Determine which artifacts to output (prefer checksummed > archived > raw) var outputArtifacts []build.Artifact @@ -243,14 +243,21 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "marshal artifacts"}), err) } fmt.Println(string(output)) + } else if !verbose { + // Minimal output: just success with artifact count + fmt.Printf("%s %s %s\n", + buildSuccessStyle.Render(i18n.T("common.label.success")), + i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)}), + buildDimStyle.Render(fmt.Sprintf("(%s)", outputDir)), + ) } return nil } // computeAndWriteChecksums computes checksums for artifacts and writes CHECKSUMS.txt. -func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool) ([]build.Artifact, error) { - if !ciMode { +func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool, verbose bool) ([]build.Artifact, error) { + if verbose && !ciMode { fmt.Println() fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.checksum")), i18n.T("cmd.build.computing_checksums")) } @@ -282,7 +289,7 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, } } - if !ciMode { + if verbose && !ciMode { for _, artifact := range checksummedArtifacts { relPath, err := filepath.Rel(projectDir, artifact.Path) if err != nil { diff --git a/pkg/build/buildcmd/cmd_pwa.go b/pkg/build/buildcmd/cmd_pwa.go index 09f3f13..c43ca1b 100644 --- a/pkg/build/buildcmd/cmd_pwa.go +++ b/pkg/build/buildcmd/cmd_pwa.go @@ -55,7 +55,7 @@ func downloadPWA(baseURL, destDir string) error { if err != nil { return fmt.Errorf("%s %s: %w", i18n.T("common.error.failed", map[string]any{"Action": "fetch URL"}), baseURL, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -152,7 +152,7 @@ func fetchManifest(manifestURL string) (map[string]interface{}, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var manifest map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { @@ -195,7 +195,7 @@ func downloadAsset(assetURL, destDir string) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() u, err := url.Parse(assetURL) if err != nil { @@ -211,7 +211,7 @@ func downloadAsset(assetURL, destDir string) error { if err != nil { return err } - defer out.Close() + defer func() { _ = out.Close() }() _, err = io.Copy(out, resp.Body) return err @@ -310,13 +310,13 @@ func copyDir(src, dst string) error { if err != nil { return err } - defer srcFile.Close() + defer func() { _ = srcFile.Close() }() dstFile, err := os.Create(dstPath) if err != nil { return err } - defer dstFile.Close() + defer func() { _ = dstFile.Close() }() _, err = io.Copy(dstFile, srcFile) return err diff --git a/pkg/build/builders/wails.go b/pkg/build/builders/wails.go index 66601a0..d14c90a 100644 --- a/pkg/build/builders/wails.go +++ b/pkg/build/builders/wails.go @@ -53,7 +53,7 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b if detected, _ := taskBuilder.Detect(cfg.ProjectDir); detected { return taskBuilder.Build(ctx, cfg, targets) } - return nil, fmt.Errorf("Wails v3 projects require a Taskfile for building") + return nil, fmt.Errorf("wails v3 projects require a Taskfile for building") } // Wails v2 strategy: Use 'wails build' @@ -102,10 +102,10 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar args = append(args, "-platform", fmt.Sprintf("%s/%s", target.OS, target.Arch)) // Output (Wails v2 uses -o for the binary name, relative to build/bin usually, but we want to control it) - // Actually, Wails v2 is opinionated about output dir (build/bin). + // Actually, Wails v2 is opinionated about output dir (build/bin). // We might need to copy artifacts after build if we want them in cfg.OutputDir. // For now, let's try to let Wails do its thing and find the artifact. - + // Create the command cmd := exec.CommandContext(ctx, "wails", args...) cmd.Dir = cfg.ProjectDir @@ -118,10 +118,10 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar // Wails v2 typically outputs to build/bin // We need to move/copy it to our desired output dir - + // Construct the source path where Wails v2 puts the binary wailsOutputDir := filepath.Join(cfg.ProjectDir, "build", "bin") - + // Find the artifact in Wails output dir sourcePath, err := b.findArtifact(wailsOutputDir, binaryName, target) if err != nil { @@ -136,7 +136,7 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar } destPath := filepath.Join(platformDir, filepath.Base(sourcePath)) - + // Simple copy input, err := os.ReadFile(sourcePath) if err != nil { @@ -252,15 +252,6 @@ func fileExists(path string) bool { return !info.IsDir() } -// dirExists checks if a directory exists. -func dirExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - return info.IsDir() -} - // fileOrDirExists checks if a file or directory exists. func fileOrDirExists(path string) bool { _, err := os.Stat(path) diff --git a/pkg/build/builders/wails_test.go b/pkg/build/builders/wails_test.go index db643be..921c2d3 100644 --- a/pkg/build/builders/wails_test.go +++ b/pkg/build/builders/wails_test.go @@ -60,35 +60,6 @@ tasks: return dir } -// setupWailsTestProjectWithFrontend creates a Wails project with frontend directory. -func setupWailsTestProjectWithFrontend(t *testing.T, lockFile string) string { - t.Helper() - dir := setupWailsTestProject(t) - - // Create frontend directory - frontendDir := filepath.Join(dir, "frontend") - err := os.MkdirAll(frontendDir, 0755) - require.NoError(t, err) - - // Create package.json - packageJSON := `{ - "name": "frontend", - "scripts": { - "build": "echo building frontend" - } -}` - err = os.WriteFile(filepath.Join(frontendDir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - - // Create lock file if specified - if lockFile != "" { - err = os.WriteFile(filepath.Join(frontendDir, lockFile), []byte(""), 0644) - require.NoError(t, err) - } - - return dir -} - // setupWailsV2TestProject creates a Wails v2 project structure. func setupWailsV2TestProject(t *testing.T) string { t.Helper() @@ -178,15 +149,11 @@ func TestWailsBuilder_Build_V2_Good(t *testing.T) { {OS: runtime.GOOS, Arch: runtime.GOARCH}, } - // This will likely fail in a real run because we can't easily mock the full wails v2 build process - // (which needs a valid project with main.go etc). + // This will likely fail in a real run because we can't easily mock the full wails v2 build process + // (which needs a valid project with main.go etc). // But it validates we are trying to run the command. - // For now, we expect an error but check it's the *right* error (from wails CLI) - _, err := builder.Build(context.Background(), cfg, targets) - if err != nil { - // If it fails, it should be because wails build failed, not because logic was wrong - // assert.Contains(t, err.Error(), "wails build failed") - } + // For now, we just verify it attempts the build - error is expected + _, _ = builder.Build(context.Background(), cfg, targets) }) } diff --git a/pkg/build/config.go b/pkg/build/config.go index 5c6fb41..7ff68c8 100644 --- a/pkg/build/config.go +++ b/pkg/build/config.go @@ -162,7 +162,7 @@ func ConfigExists(dir string) bool { func (cfg *BuildConfig) ToTargets() []Target { targets := make([]Target, len(cfg.Targets)) for i, t := range cfg.Targets { - targets[i] = Target{OS: t.OS, Arch: t.Arch} + targets[i] = Target(t) } return targets } diff --git a/pkg/build/signing/codesign_test.go b/pkg/build/signing/codesign_test.go index ecbd40f..61dcb41 100644 --- a/pkg/build/signing/codesign_test.go +++ b/pkg/build/signing/codesign_test.go @@ -53,7 +53,7 @@ func TestMacOSSigner_Notarize_Bad(t *testing.T) { func TestMacOSSigner_ShouldNotarize(t *testing.T) { s := NewMacOSSigner(MacOSConfig{Notarize: true}) assert.True(t, s.ShouldNotarize()) - + s2 := NewMacOSSigner(MacOSConfig{Notarize: false}) assert.False(t, s2.ShouldNotarize()) -} \ No newline at end of file +} diff --git a/pkg/build/signing/gpg_test.go b/pkg/build/signing/gpg_test.go index f53aac0..6293f7c 100644 --- a/pkg/build/signing/gpg_test.go +++ b/pkg/build/signing/gpg_test.go @@ -29,4 +29,4 @@ func TestGPGSigner_Sign_Bad(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "not available or key not configured") }) -} \ No newline at end of file +} diff --git a/pkg/ci/cmd_changelog.go b/pkg/ci/cmd_changelog.go index 6904cb8..f6be9f2 100644 --- a/pkg/ci/cmd_changelog.go +++ b/pkg/ci/cmd_changelog.go @@ -54,4 +54,4 @@ func latestTag(dir string) (string, error) { return "", err } return strings.TrimSpace(string(out)), nil -} \ No newline at end of file +} diff --git a/pkg/ci/cmd_init.go b/pkg/ci/cmd_init.go index cb3b50d..59e4958 100644 --- a/pkg/ci/cmd_init.go +++ b/pkg/ci/cmd_init.go @@ -40,4 +40,4 @@ func runCIReleaseInit() error { cli.Print(" %s\n", i18n.T("cmd.ci.init.run_ci")) return nil -} \ No newline at end of file +} diff --git a/pkg/cli/ansi.go b/pkg/cli/ansi.go index 4f45093..200fc86 100644 --- a/pkg/cli/ansi.go +++ b/pkg/cli/ansi.go @@ -123,4 +123,4 @@ func hexToRGB(hex string) (int, int, int) { g, _ := strconv.ParseUint(hex[2:4], 16, 8) b, _ := strconv.ParseUint(hex[4:6], 16, 8) return int(r), int(g), int(b) -} \ No newline at end of file +} diff --git a/pkg/cli/check.go b/pkg/cli/check.go index a6c9e9e..499cd89 100644 --- a/pkg/cli/check.go +++ b/pkg/cli/check.go @@ -88,4 +88,4 @@ func (c *CheckBuilder) String() string { // Print outputs the check result. func (c *CheckBuilder) Print() { fmt.Println(c.String()) -} \ No newline at end of file +} diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go index 74cb0c7..8599eb5 100644 --- a/pkg/cli/daemon.go +++ b/pkg/cli/daemon.go @@ -100,7 +100,7 @@ func (p *PIDFile) Acquire() error { } } // Stale PID file, remove it - os.Remove(p.path) + _ = os.Remove(p.path) } // Ensure directory exists @@ -183,13 +183,13 @@ func (h *HealthServer) Start() error { for _, check := range checks { if err := check(); err != nil { w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintf(w, "unhealthy: %v\n", err) + _, _ = fmt.Fprintf(w, "unhealthy: %v\n", err) return } } w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "ok") + _, _ = fmt.Fprintln(w, "ok") }) mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { @@ -199,12 +199,12 @@ func (h *HealthServer) Start() error { if !ready { w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintln(w, "not ready") + _, _ = fmt.Fprintln(w, "not ready") return } w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "ready") + _, _ = fmt.Fprintln(w, "ready") }) listener, err := net.Listen("tcp", h.addr) @@ -322,7 +322,7 @@ func (d *Daemon) Start() error { if d.health != nil { if err := d.health.Start(); err != nil { if d.pid != nil { - d.pid.Release() + _ = d.pid.Release() } return err } diff --git a/pkg/cli/daemon_test.go b/pkg/cli/daemon_test.go index 214822b..d24fc01 100644 --- a/pkg/cli/daemon_test.go +++ b/pkg/cli/daemon_test.go @@ -96,7 +96,7 @@ func TestHealthServer(t *testing.T) { err := hs.Start() require.NoError(t, err) - defer hs.Stop(context.Background()) + defer func() { _ = hs.Stop(context.Background()) }() addr := hs.Addr() require.NotEmpty(t, addr) @@ -135,7 +135,7 @@ func TestHealthServer(t *testing.T) { err := hs.Start() require.NoError(t, err) - defer hs.Stop(context.Background()) + defer func() { _ = hs.Stop(context.Background()) }() addr := hs.Addr() @@ -193,7 +193,7 @@ func TestDaemon(t *testing.T) { err := d.Start() require.NoError(t, err) - defer d.Stop() + defer func() { _ = d.Stop() }() err = d.Start() assert.Error(t, err) @@ -218,7 +218,7 @@ func TestDaemon(t *testing.T) { err := d.Start() require.NoError(t, err) - defer d.Stop() + defer func() { _ = d.Stop() }() addr := d.HealthAddr() diff --git a/pkg/cli/glyph.go b/pkg/cli/glyph.go index 28ca5fd..97143ec 100644 --- a/pkg/cli/glyph.go +++ b/pkg/cli/glyph.go @@ -86,4 +86,4 @@ func replaceGlyph(input *bytes.Buffer) string { return Glyph(code.String()) } } -} \ No newline at end of file +} diff --git a/pkg/cli/i18n.go b/pkg/cli/i18n.go index 32e8055..c38b6fa 100644 --- a/pkg/cli/i18n.go +++ b/pkg/cli/i18n.go @@ -35,7 +35,7 @@ func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) { } if opts.Language != "" { - svc.SetLanguage(opts.Language) + _ = svc.SetLanguage(opts.Language) } // Set mode if specified @@ -131,7 +131,7 @@ func (s *I18nService) T(key string, args ...map[string]any) string { // SetLanguage changes the current language. func (s *I18nService) SetLanguage(lang string) { - s.svc.SetLanguage(lang) + _ = s.svc.SetLanguage(lang) } // Language returns the current language. diff --git a/pkg/cli/layout.go b/pkg/cli/layout.go index d4feb57..a8aedbb 100644 --- a/pkg/cli/layout.go +++ b/pkg/cli/layout.go @@ -7,15 +7,15 @@ type Region rune const ( // RegionHeader is the top region of the layout. - RegionHeader Region = 'H' + RegionHeader Region = 'H' // RegionLeft is the left sidebar region. - RegionLeft Region = 'L' + RegionLeft Region = 'L' // RegionContent is the main content region. RegionContent Region = 'C' // RegionRight is the right sidebar region. - RegionRight Region = 'R' + RegionRight Region = 'R' // RegionFooter is the bottom region of the layout. - RegionFooter Region = 'F' + RegionFooter Region = 'F' ) // Composite represents an HLCRF layout node. @@ -98,9 +98,10 @@ func isValidRegion(r Region) bool { func findMatchingBracket(s string, start int) int { depth := 0 for i := start; i < len(s); i++ { - if s[i] == '[' { + switch s[i] { + case '[': depth++ - } else if s[i] == ']' { + case ']': depth-- if depth == 0 { return i @@ -144,4 +145,4 @@ func toRenderable(item any) Renderable { default: return StringBlock(fmt.Sprint(v)) } -} \ No newline at end of file +} diff --git a/pkg/cli/layout_test.go b/pkg/cli/layout_test.go index a49504e..4fb42ad 100644 --- a/pkg/cli/layout_test.go +++ b/pkg/cli/layout_test.go @@ -13,7 +13,7 @@ func TestParseVariant(t *testing.T) { if _, ok := c.regions[RegionFooter]; !ok { t.Error("Expected Footer region") } - + hSlot := c.regions[RegionHeader] if hSlot.child == nil { t.Error("Header should have child layout") diff --git a/pkg/cli/log.go b/pkg/cli/log.go index 8b81dd7..2cc542e 100644 --- a/pkg/cli/log.go +++ b/pkg/cli/log.go @@ -8,11 +8,17 @@ import ( // LogLevel aliases for backwards compatibility. type LogLevel = log.Level +// Log level constants aliased from the log package. const ( + // LogLevelQuiet suppresses all output. LogLevelQuiet = log.LevelQuiet + // LogLevelError shows only error messages. LogLevelError = log.LevelError - LogLevelWarn = log.LevelWarn - LogLevelInfo = log.LevelInfo + // LogLevelWarn shows warnings and errors. + LogLevelWarn = log.LevelWarn + // LogLevelInfo shows info, warnings, and errors. + LogLevelInfo = log.LevelInfo + // LogLevelDebug shows all messages including debug. LogLevelDebug = log.LevelDebug ) diff --git a/pkg/cli/output.go b/pkg/cli/output.go index b785e96..670bda2 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -163,4 +163,4 @@ func Result(passed bool, message string) { } else { Error(message) } -} \ No newline at end of file +} diff --git a/pkg/cli/output_test.go b/pkg/cli/output_test.go index 25f1cfe..34f6a32 100644 --- a/pkg/cli/output_test.go +++ b/pkg/cli/output_test.go @@ -14,11 +14,11 @@ func captureOutput(f func()) string { f() - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - io.Copy(&buf, r) + _, _ = io.Copy(&buf, r) return buf.String() } diff --git a/pkg/cli/prompt.go b/pkg/cli/prompt.go index 26a0b63..d9eb993 100644 --- a/pkg/cli/prompt.go +++ b/pkg/cli/prompt.go @@ -30,8 +30,6 @@ func Prompt(label, defaultVal string) (string, error) { return input, nil } - - // Select presents numbered options and returns the selected value. func Select(label string, options []string) (string, error) { fmt.Println(label) diff --git a/pkg/cli/render.go b/pkg/cli/render.go index d97b714..95bb05c 100644 --- a/pkg/cli/render.go +++ b/pkg/cli/render.go @@ -8,17 +8,26 @@ import ( // RenderStyle controls how layouts are rendered. type RenderStyle int +// Render style constants for layout output. const ( - RenderFlat RenderStyle = iota // No borders - RenderSimple // --- separators - RenderBoxed // Unicode box drawing + // RenderFlat uses no borders or decorations. + RenderFlat RenderStyle = iota + // RenderSimple uses --- separators between sections. + RenderSimple + // RenderBoxed uses Unicode box drawing characters. + RenderBoxed ) var currentRenderStyle = RenderFlat -func UseRenderFlat() { currentRenderStyle = RenderFlat } +// UseRenderFlat sets the render style to flat (no borders). +func UseRenderFlat() { currentRenderStyle = RenderFlat } + +// UseRenderSimple sets the render style to simple (--- separators). func UseRenderSimple() { currentRenderStyle = RenderSimple } -func UseRenderBoxed() { currentRenderStyle = RenderBoxed } + +// UseRenderBoxed sets the render style to boxed (Unicode box drawing). +func UseRenderBoxed() { currentRenderStyle = RenderBoxed } // Render outputs the layout to terminal. func (c *Composite) Render() { diff --git a/pkg/cli/runtime.go b/pkg/cli/runtime.go index 1e14e71..28de670 100644 --- a/pkg/cli/runtime.go +++ b/pkg/cli/runtime.go @@ -141,11 +141,9 @@ func Shutdown() { return } instance.cancel() - instance.core.ServiceShutdown(instance.ctx) + _ = instance.core.ServiceShutdown(instance.ctx) } - - // --- Signal Service (internal) --- type signalService struct { diff --git a/pkg/cli/strings.go b/pkg/cli/strings.go index 9e4240b..1e587ad 100644 --- a/pkg/cli/strings.go +++ b/pkg/cli/strings.go @@ -45,4 +45,4 @@ func InfoStr(msg string) string { // DimStr returns dim-styled string. func DimStr(msg string) string { return DimStyle.Render(msg) -} \ No newline at end of file +} diff --git a/pkg/cli/styles.go b/pkg/cli/styles.go index 985d3de..6b776f9 100644 --- a/pkg/cli/styles.go +++ b/pkg/cli/styles.go @@ -9,41 +9,41 @@ import ( // Tailwind colour palette (hex strings) const ( - ColourBlue50 = "#eff6ff" - ColourBlue100 = "#dbeafe" - ColourBlue200 = "#bfdbfe" - ColourBlue300 = "#93c5fd" - ColourBlue400 = "#60a5fa" - ColourBlue500 = "#3b82f6" - ColourBlue600 = "#2563eb" - ColourBlue700 = "#1d4ed8" - ColourGreen400 = "#4ade80" - ColourGreen500 = "#22c55e" - ColourGreen600 = "#16a34a" - ColourRed400 = "#f87171" - ColourRed500 = "#ef4444" - ColourRed600 = "#dc2626" - ColourAmber400 = "#fbbf24" - ColourAmber500 = "#f59e0b" - ColourAmber600 = "#d97706" - ColourOrange500 = "#f97316" - ColourYellow500 = "#eab308" - ColourEmerald500= "#10b981" - ColourPurple500 = "#a855f7" - ColourViolet400 = "#a78bfa" - ColourViolet500 = "#8b5cf6" - ColourIndigo500 = "#6366f1" - ColourCyan500 = "#06b6d4" - ColourGray50 = "#f9fafb" - ColourGray100 = "#f3f4f6" - ColourGray200 = "#e5e7eb" - ColourGray300 = "#d1d5db" - ColourGray400 = "#9ca3af" - ColourGray500 = "#6b7280" - ColourGray600 = "#4b5563" - ColourGray700 = "#374151" - ColourGray800 = "#1f2937" - ColourGray900 = "#111827" + ColourBlue50 = "#eff6ff" + ColourBlue100 = "#dbeafe" + ColourBlue200 = "#bfdbfe" + ColourBlue300 = "#93c5fd" + ColourBlue400 = "#60a5fa" + ColourBlue500 = "#3b82f6" + ColourBlue600 = "#2563eb" + ColourBlue700 = "#1d4ed8" + ColourGreen400 = "#4ade80" + ColourGreen500 = "#22c55e" + ColourGreen600 = "#16a34a" + ColourRed400 = "#f87171" + ColourRed500 = "#ef4444" + ColourRed600 = "#dc2626" + ColourAmber400 = "#fbbf24" + ColourAmber500 = "#f59e0b" + ColourAmber600 = "#d97706" + ColourOrange500 = "#f97316" + ColourYellow500 = "#eab308" + ColourEmerald500 = "#10b981" + ColourPurple500 = "#a855f7" + ColourViolet400 = "#a78bfa" + ColourViolet500 = "#8b5cf6" + ColourIndigo500 = "#6366f1" + ColourCyan500 = "#06b6d4" + ColourGray50 = "#f9fafb" + ColourGray100 = "#f3f4f6" + ColourGray200 = "#e5e7eb" + ColourGray300 = "#d1d5db" + ColourGray400 = "#9ca3af" + ColourGray500 = "#6b7280" + ColourGray600 = "#4b5563" + ColourGray700 = "#374151" + ColourGray800 = "#1f2937" + ColourGray900 = "#111827" ) // Core styles @@ -112,6 +112,7 @@ type Table struct { Style TableStyle } +// TableStyle configures the appearance of table output. type TableStyle struct { HeaderStyle *AnsiStyle CellStyle *AnsiStyle @@ -206,4 +207,4 @@ func (t *Table) String() string { // Render prints the table to stdout. func (t *Table) Render() { fmt.Print(t.String()) -} \ No newline at end of file +} diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go index e7294aa..667c3a6 100644 --- a/pkg/cli/utils.go +++ b/pkg/cli/utils.go @@ -20,8 +20,6 @@ func GhAuthenticated() bool { return strings.Contains(string(output), "Logged in") } - - // ConfirmOption configures Confirm behaviour. type ConfirmOption func(*confirmConfig) @@ -473,8 +471,6 @@ func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOpt return ChooseMulti(question, items, opts...) } - - // GitClone clones a GitHub repository to the specified path. // Prefers 'gh repo clone' if authenticated, falls back to SSH. func GitClone(ctx context.Context, org, repo, path string) error { diff --git a/pkg/container/hypervisor.go b/pkg/container/hypervisor.go index b5c1e5f..dbf151f 100644 --- a/pkg/container/hypervisor.go +++ b/pkg/container/hypervisor.go @@ -180,8 +180,8 @@ func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opt args := []string{ "-m", fmt.Sprintf("%dM", opts.Memory), "-c", fmt.Sprintf("%d", opts.CPUs), - "-A", // ACPI - "-u", // Unlimited console output + "-A", // ACPI + "-u", // Unlimited console output "-s", "0:0,hostbridge", "-s", "31,lpc", "-l", "com1,stdio", // Serial console diff --git a/pkg/container/linuxkit.go b/pkg/container/linuxkit.go index 8bf34d5..25c1ca1 100644 --- a/pkg/container/linuxkit.go +++ b/pkg/container/linuxkit.go @@ -137,7 +137,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions // Start the process if err := cmd.Start(); err != nil { - logFile.Close() + _ = logFile.Close() return nil, fmt.Errorf("failed to start VM: %w", err) } @@ -146,13 +146,13 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions // Save state if err := m.state.Add(container); err != nil { // Try to kill the process we just started - cmd.Process.Kill() - logFile.Close() + _ = cmd.Process.Kill() + _ = logFile.Close() return nil, fmt.Errorf("failed to save state: %w", err) } // Close log file handle (process has its own) - logFile.Close() + _ = logFile.Close() // Start a goroutine to wait for process exit and update state go m.waitForExit(container.ID, cmd) @@ -170,12 +170,12 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions stderr, err := cmd.StderrPipe() if err != nil { - logFile.Close() + _ = logFile.Close() return nil, fmt.Errorf("failed to get stderr pipe: %w", err) } if err := cmd.Start(); err != nil { - logFile.Close() + _ = logFile.Close() return nil, fmt.Errorf("failed to start VM: %w", err) } @@ -183,19 +183,19 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions // Save state before waiting if err := m.state.Add(container); err != nil { - cmd.Process.Kill() - logFile.Close() + _ = cmd.Process.Kill() + _ = logFile.Close() return nil, fmt.Errorf("failed to save state: %w", err) } // Copy output to both log and stdout go func() { mw := io.MultiWriter(logFile, os.Stdout) - io.Copy(mw, stdout) + _, _ = io.Copy(mw, stdout) }() go func() { mw := io.MultiWriter(logFile, os.Stderr) - io.Copy(mw, stderr) + _, _ = io.Copy(mw, stderr) }() // Wait for the process to complete @@ -205,20 +205,26 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions container.Status = StatusStopped } - logFile.Close() - m.state.Update(container) + _ = logFile.Close() + if err := m.state.Update(container); err != nil { + return container, fmt.Errorf("update container state: %w", err) + } return container, nil } // waitForExit monitors a detached process and updates state when it exits. func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) { - cmd.Wait() + err := cmd.Wait() container, ok := m.state.Get(id) if ok { - container.Status = StatusStopped - m.state.Update(container) + if err != nil { + container.Status = StatusError + } else { + container.Status = StatusStopped + } + _ = m.state.Update(container) } } @@ -238,7 +244,7 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { if err != nil { // Process doesn't exist, update state container.Status = StatusStopped - m.state.Update(container) + _ = m.state.Update(container) return nil } @@ -246,14 +252,14 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { if err := process.Signal(syscall.SIGTERM); err != nil { // Process might already be gone container.Status = StatusStopped - m.state.Update(container) + _ = m.state.Update(container) return nil } // Wait for graceful shutdown with timeout done := make(chan struct{}) go func() { - process.Wait() + _, _ = process.Wait() close(done) }() @@ -262,11 +268,11 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { // Process exited gracefully case <-time.After(10 * time.Second): // Force kill - process.Signal(syscall.SIGKILL) + _ = process.Signal(syscall.SIGKILL) <-done case <-ctx.Done(): // Context cancelled - process.Signal(syscall.SIGKILL) + _ = process.Signal(syscall.SIGKILL) return ctx.Err() } @@ -283,7 +289,7 @@ func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) { if c.Status == StatusRunning { if !isProcessRunning(c.PID) { c.Status = StatusStopped - m.state.Update(c) + _ = m.state.Update(c) } } } @@ -346,7 +352,7 @@ func newFollowReader(ctx context.Context, path string) (*followReader, error) { } // Seek to end - file.Seek(0, io.SeekEnd) + _, _ = file.Seek(0, io.SeekEnd) ctx, cancel := context.WithCancel(ctx) diff --git a/pkg/container/linuxkit_test.go b/pkg/container/linuxkit_test.go index 5c65393..46122f2 100644 --- a/pkg/container/linuxkit_test.go +++ b/pkg/container/linuxkit_test.go @@ -186,7 +186,7 @@ func TestLinuxKitManager_Stop_Good(t *testing.T) { PID: 999999, // Non-existent PID StartedAt: time.Now(), } - manager.State().Add(container) + _ = manager.State().Add(container) ctx := context.Background() err := manager.Stop(ctx, "abc12345") @@ -213,17 +213,18 @@ func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) { func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) { manager, _, tmpDir := newTestManager(t) statePath := filepath.Join(tmpDir, "containers.json") - state, _ := LoadState(statePath) + state, err := LoadState(statePath) + require.NoError(t, err) manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor()) container := &Container{ ID: "abc12345", Status: StatusStopped, } - state.Add(container) + _ = state.Add(container) ctx := context.Background() - err := manager.Stop(ctx, "abc12345") + err = manager.Stop(ctx, "abc12345") assert.Error(t, err) assert.Contains(t, err.Error(), "not running") @@ -232,11 +233,12 @@ func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) { func TestLinuxKitManager_List_Good(t *testing.T) { manager, _, tmpDir := newTestManager(t) statePath := filepath.Join(tmpDir, "containers.json") - state, _ := LoadState(statePath) + state, err := LoadState(statePath) + require.NoError(t, err) manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor()) - state.Add(&Container{ID: "aaa11111", Status: StatusStopped}) - state.Add(&Container{ID: "bbb22222", Status: StatusStopped}) + _ = state.Add(&Container{ID: "aaa11111", Status: StatusStopped}) + _ = state.Add(&Container{ID: "bbb22222", Status: StatusStopped}) ctx := context.Background() containers, err := manager.List(ctx) @@ -248,11 +250,12 @@ func TestLinuxKitManager_List_Good(t *testing.T) { func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) { manager, _, tmpDir := newTestManager(t) statePath := filepath.Join(tmpDir, "containers.json") - state, _ := LoadState(statePath) + state, err := LoadState(statePath) + require.NoError(t, err) manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor()) // Add a "running" container with a fake PID that doesn't exist - state.Add(&Container{ + _ = state.Add(&Container{ ID: "abc12345", Status: StatusRunning, PID: 999999, // PID that almost certainly doesn't exist @@ -272,17 +275,18 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) { // Create a log file manually logsDir := filepath.Join(tmpDir, "logs") - os.MkdirAll(logsDir, 0755) + require.NoError(t, os.MkdirAll(logsDir, 0755)) container := &Container{ID: "abc12345"} - manager.State().Add(container) + _ = manager.State().Add(container) // Override the default logs dir for testing by creating the log file // at the expected location logContent := "test log content\nline 2\n" - logPath, _ := LogPath("abc12345") - os.MkdirAll(filepath.Dir(logPath), 0755) - os.WriteFile(logPath, []byte(logContent), 0644) + logPath, err := LogPath("abc12345") + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755)) + require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644)) ctx := context.Background() reader, err := manager.Logs(ctx, "abc12345", false) @@ -309,9 +313,10 @@ func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) { manager, _, _ := newTestManager(t) // Use a unique ID that won't have a log file - uniqueID, _ := GenerateID() + uniqueID, err := GenerateID() + require.NoError(t, err) container := &Container{ID: uniqueID} - manager.State().Add(container) + _ = manager.State().Add(container) ctx := context.Background() reader, err := manager.Logs(ctx, uniqueID, false) @@ -341,7 +346,7 @@ func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) { manager, _, _ := newTestManager(t) container := &Container{ID: "abc12345", Status: StatusStopped} - manager.State().Add(container) + _ = manager.State().Add(container) ctx := context.Background() err := manager.Exec(ctx, "abc12345", []string{"ls"}) @@ -420,19 +425,19 @@ func TestQemuHypervisor_BuildCommand_Good(t *testing.T) { assert.Contains(t, args, "-nographic") } - func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { manager, _, _ := newTestManager(t) // Create a unique container ID - uniqueID, _ := GenerateID() + uniqueID, err := GenerateID() + require.NoError(t, err) container := &Container{ID: uniqueID} - manager.State().Add(container) + _ = manager.State().Add(container) // Create a log file at the expected location logPath, err := LogPath(uniqueID) require.NoError(t, err) - os.MkdirAll(filepath.Dir(logPath), 0755) + require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755)) // Write initial content err = os.WriteFile(logPath, []byte("initial log content\n"), 0644) @@ -455,8 +460,7 @@ func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { assert.Equal(t, "EOF", readErr.Error()) // Close the reader - err = reader.Close() - assert.NoError(t, err) + assert.NoError(t, reader.Close()) } func TestFollowReader_Read_Good_WithData(t *testing.T) { @@ -480,7 +484,7 @@ func TestFollowReader_Read_Good_WithData(t *testing.T) { require.NoError(t, err) _, err = f.WriteString("new line\n") require.NoError(t, err) - f.Close() + require.NoError(t, f.Close()) // Give the reader time to poll time.Sleep(150 * time.Millisecond) @@ -513,7 +517,7 @@ func TestFollowReader_Read_Good_ContextCancel(t *testing.T) { _, readErr := reader.Read(buf) assert.Equal(t, "EOF", readErr.Error()) - reader.Close() + _ = reader.Close() } func TestFollowReader_Close_Good(t *testing.T) { @@ -766,7 +770,7 @@ func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) { PID: 999999, // Non-existent PID StartedAt: time.Now(), } - manager.State().Add(container) + _ = manager.State().Add(container) ctx := context.Background() err := manager.Stop(ctx, "test1234") diff --git a/pkg/dev/cmd_apply.go b/pkg/dev/cmd_apply.go index ac03eb9..1677437 100644 --- a/pkg/dev/cmd_apply.go +++ b/pkg/dev/cmd_apply.go @@ -23,19 +23,19 @@ import ( // Apply command flags var ( - applyCommand string - applyScript string - applyRepos string - applyCommit bool - applyMessage string - applyCoAuthor string - applyDryRun bool - applyPush bool - applyContinue bool // Continue on error + applyCommand string + applyScript string + applyRepos string + applyCommit bool + applyMessage string + applyCoAuthor string + applyDryRun bool + applyPush bool + applyContinue bool // Continue on error ) -// addApplyCommand adds the 'apply' command to dev. -func addApplyCommand(parent *cli.Command) { +// AddApplyCommand adds the 'apply' command to dev. +func AddApplyCommand(parent *cli.Command) { applyCmd := &cli.Command{ Use: "apply", Short: i18n.T("cmd.dev.apply.short"), diff --git a/pkg/dev/cmd_bundles.go b/pkg/dev/cmd_bundles.go index e2374e2..d877527 100644 --- a/pkg/dev/cmd_bundles.go +++ b/pkg/dev/cmd_bundles.go @@ -63,9 +63,7 @@ type StatusBundleOptions struct { // Includes: dev (orchestration), git services. No agentic - commits not available. func NewStatusBundle(opts StatusBundleOptions) (*StatusBundle, error) { c, err := framework.New( - framework.WithService(NewService(ServiceOptions{ - RegistryPath: opts.RegistryPath, - })), + framework.WithService(NewService(ServiceOptions(opts))), framework.WithService(git.NewService(git.ServiceOptions{})), // No agentic service - TaskCommit will be unhandled framework.WithServiceLock(), diff --git a/pkg/dev/cmd_ci.go b/pkg/dev/cmd_ci.go index 660b2df..de0ef97 100644 --- a/pkg/dev/cmd_ci.go +++ b/pkg/dev/cmd_ci.go @@ -229,11 +229,12 @@ func printWorkflowRun(run WorkflowRun) { case "failure": status = ciFailureStyle.Render("x") case "": - if run.Status == "in_progress" { + switch run.Status { + case "in_progress": status = ciPendingStyle.Render("*") - } else if run.Status == "queued" { + case "queued": status = ciPendingStyle.Render("o") - } else { + default: status = ciSkippedStyle.Render("-") } case "skipped": diff --git a/pkg/dev/cmd_commit.go b/pkg/dev/cmd_commit.go index 55fad3f..3533298 100644 --- a/pkg/dev/cmd_commit.go +++ b/pkg/dev/cmd_commit.go @@ -16,8 +16,8 @@ var ( commitAll bool ) -// addCommitCommand adds the 'commit' command to the given parent command. -func addCommitCommand(parent *cli.Command) { +// AddCommitCommand adds the 'commit' command to the given parent command. +func AddCommitCommand(parent *cli.Command) { commitCmd := &cli.Command{ Use: "commit", Short: i18n.T("cmd.dev.commit.short"), @@ -197,4 +197,4 @@ func runCommitSingleRepo(ctx context.Context, repoPath string, all bool) error { } cli.Print(" %s %s\n", successStyle.Render("v"), i18n.T("cmd.dev.committed")) return nil -} \ No newline at end of file +} diff --git a/pkg/dev/cmd_dev.go b/pkg/dev/cmd_dev.go index 2cbe57d..f8443ca 100644 --- a/pkg/dev/cmd_dev.go +++ b/pkg/dev/cmd_dev.go @@ -68,16 +68,16 @@ func AddDevCommands(root *cli.Command) { } root.AddCommand(devCmd) - // Git operations - addWorkCommand(devCmd) - addHealthCommand(devCmd) - addCommitCommand(devCmd) - addPushCommand(devCmd) - addPullCommand(devCmd) + // Git operations (also available under 'core git') + AddWorkCommand(devCmd) + AddHealthCommand(devCmd) + AddCommitCommand(devCmd) + AddPushCommand(devCmd) + AddPullCommand(devCmd) - // Safe git operations for AI agents - addFileSyncCommand(devCmd) - addApplyCommand(devCmd) + // Safe git operations for AI agents (also available under 'core git') + AddFileSyncCommand(devCmd) + AddApplyCommand(devCmd) // GitHub integration addIssuesCommand(devCmd) diff --git a/pkg/dev/cmd_file_sync.go b/pkg/dev/cmd_file_sync.go index 6dbd8a7..45ef2c9 100644 --- a/pkg/dev/cmd_file_sync.go +++ b/pkg/dev/cmd_file_sync.go @@ -31,8 +31,8 @@ var ( fileSyncPush bool ) -// addFileSyncCommand adds the 'sync' command to dev for file syncing. -func addFileSyncCommand(parent *cli.Command) { +// AddFileSyncCommand adds the 'sync' command to dev for file syncing. +func AddFileSyncCommand(parent *cli.Command) { syncCmd := &cli.Command{ Use: "sync ", Short: i18n.T("cmd.dev.file_sync.short"), diff --git a/pkg/dev/cmd_health.go b/pkg/dev/cmd_health.go index f1ed360..49889d6 100644 --- a/pkg/dev/cmd_health.go +++ b/pkg/dev/cmd_health.go @@ -17,8 +17,8 @@ var ( healthVerbose bool ) -// addHealthCommand adds the 'health' command to the given parent command. -func addHealthCommand(parent *cli.Command) { +// AddHealthCommand adds the 'health' command to the given parent command. +func AddHealthCommand(parent *cli.Command) { healthCmd := &cli.Command{ Use: "health", Short: i18n.T("cmd.dev.health.short"), diff --git a/pkg/dev/cmd_issues.go b/pkg/dev/cmd_issues.go index 834a7b5..1108d0c 100644 --- a/pkg/dev/cmd_issues.go +++ b/pkg/dev/cmd_issues.go @@ -113,7 +113,7 @@ func runIssues(registryPath string, limit int, assignee string) error { // Print issues if len(allIssues) == 0 { - cli.Text(i18n.T("cmd.dev.issues.no_issues")) + cli.Text(i18n.T("cmd.dev.issues.no_issues")) return nil } @@ -204,5 +204,5 @@ func printIssue(issue GitHubIssue) { age := cli.FormatAge(issue.CreatedAt) line += " " + issueAgeStyle.Render(age) - cli.Text(line) + cli.Text(line) } diff --git a/pkg/dev/cmd_pull.go b/pkg/dev/cmd_pull.go index 1b29b7f..80b37ce 100644 --- a/pkg/dev/cmd_pull.go +++ b/pkg/dev/cmd_pull.go @@ -15,8 +15,8 @@ var ( pullAll bool ) -// addPullCommand adds the 'pull' command to the given parent command. -func addPullCommand(parent *cli.Command) { +// AddPullCommand adds the 'pull' command to the given parent command. +func AddPullCommand(parent *cli.Command) { pullCmd := &cli.Command{ Use: "pull", Short: i18n.T("cmd.dev.pull.short"), diff --git a/pkg/dev/cmd_push.go b/pkg/dev/cmd_push.go index 173ed38..ff087b3 100644 --- a/pkg/dev/cmd_push.go +++ b/pkg/dev/cmd_push.go @@ -16,8 +16,8 @@ var ( pushForce bool ) -// addPushCommand adds the 'push' command to the given parent command. -func addPushCommand(parent *cli.Command) { +// AddPushCommand adds the 'push' command to the given parent command. +func AddPushCommand(parent *cli.Command) { pushCmd := &cli.Command{ Use: "push", Short: i18n.T("cmd.dev.push.short"), diff --git a/pkg/dev/cmd_work.go b/pkg/dev/cmd_work.go index 07d98d5..5f37fba 100644 --- a/pkg/dev/cmd_work.go +++ b/pkg/dev/cmd_work.go @@ -20,8 +20,8 @@ var ( workRegistryPath string ) -// addWorkCommand adds the 'work' command to the given parent command. -func addWorkCommand(parent *cli.Command) { +// AddWorkCommand adds the 'work' command to the given parent command. +func AddWorkCommand(parent *cli.Command) { workCmd := &cli.Command{ Use: "work", Short: i18n.T("cmd.dev.work.short"), @@ -342,5 +342,3 @@ func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath stri return cmd.Run() } - - diff --git a/pkg/dev/registry.go b/pkg/dev/registry.go index 8d4b9b8..d51c0af 100644 --- a/pkg/dev/registry.go +++ b/pkg/dev/registry.go @@ -56,7 +56,7 @@ func loadRegistryWithConfig(registryPath string) (*repos.Registry, string, error if !filepath.IsAbs(pkgDir) { pkgDir = filepath.Join(registryDir, pkgDir) } - + // Update repo paths for _, repo := range reg.Repos { repo.Path = filepath.Join(pkgDir, repo.Name) diff --git a/pkg/devops/config_test.go b/pkg/devops/config_test.go index 7f1c1f5..ddfd552 100644 --- a/pkg/devops/config_test.go +++ b/pkg/devops/config_test.go @@ -251,4 +251,4 @@ func TestLoadConfig_Bad_UnreadableFile(t *testing.T) { // Restore permissions so cleanup works os.Chmod(configPath, 0644) -} \ No newline at end of file +} diff --git a/pkg/devops/devops_test.go b/pkg/devops/devops_test.go index c3b8a2e..2e456b9 100644 --- a/pkg/devops/devops_test.go +++ b/pkg/devops/devops_test.go @@ -91,7 +91,7 @@ func TestIsInstalled_Good(t *testing.T) { type mockHypervisor struct{} -func (m *mockHypervisor) Name() string { return "mock" } +func (m *mockHypervisor) Name() string { return "mock" } func (m *mockHypervisor) Available() bool { return true } func (m *mockHypervisor) BuildCommand(ctx context.Context, image string, opts *container.HypervisorOptions) (*exec.Cmd, error) { return exec.Command("true"), nil diff --git a/pkg/devops/images_test.go b/pkg/devops/images_test.go index b186e54..a9edb35 100644 --- a/pkg/devops/images_test.go +++ b/pkg/devops/images_test.go @@ -39,7 +39,7 @@ func TestNewImageManager_Good(t *testing.T) { cfg := DefaultConfig() cfg.Images.Source = "cdn" - + mgr, err := NewImageManager(cfg) assert.NoError(t, err) assert.NotNil(t, mgr) @@ -53,7 +53,7 @@ func TestNewImageManager_Good(t *testing.T) { cfg := DefaultConfig() cfg.Images.Source = "github" - + mgr, err := NewImageManager(cfg) assert.NoError(t, err) assert.NotNil(t, mgr) @@ -65,24 +65,24 @@ func TestNewImageManager_Good(t *testing.T) { func TestManifest_Save(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "manifest.json") - + m := &Manifest{ Images: make(map[string]ImageInfo), path: path, } - + m.Images["test.img"] = ImageInfo{ Version: "1.0.0", Source: "test", } - + err := m.Save() assert.NoError(t, err) - + // Verify file exists and has content _, err = os.Stat(path) assert.NoError(t, err) - + // Reload m2, err := loadManifest(path) assert.NoError(t, err) @@ -95,7 +95,7 @@ func TestLoadManifest_Bad(t *testing.T) { path := filepath.Join(tmpDir, "manifest.json") err := os.WriteFile(path, []byte("invalid json"), 0644) require.NoError(t, err) - + _, err = loadManifest(path) assert.Error(t, err) }) @@ -269,7 +269,7 @@ type mockImageSource struct { downloadErr error } -func (m *mockImageSource) Name() string { return m.name } +func (m *mockImageSource) Name() string { return m.name } func (m *mockImageSource) Available() bool { return m.available } func (m *mockImageSource) LatestVersion(ctx context.Context) (string, error) { return m.latestVersion, m.latestErr @@ -555,4 +555,4 @@ func TestManifest_Struct(t *testing.T) { assert.Equal(t, "/path/to/manifest.json", m.path) assert.Len(t, m.Images, 1) assert.Equal(t, "1.0.0", m.Images["test.img"].Version) -} \ No newline at end of file +} diff --git a/pkg/devops/sources/cdn.go b/pkg/devops/sources/cdn.go index 851fe0e..4af8659 100644 --- a/pkg/devops/sources/cdn.go +++ b/pkg/devops/sources/cdn.go @@ -45,7 +45,7 @@ func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) { if err != nil || resp.StatusCode != 200 { return "latest", nil } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // For now, just return latest - could parse manifest for version return "latest", nil @@ -64,7 +64,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow if err != nil { return fmt.Errorf("cdn.Download: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { return fmt.Errorf("cdn.Download: HTTP %d", resp.StatusCode) @@ -81,7 +81,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow if err != nil { return fmt.Errorf("cdn.Download: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() // Copy with progress total := resp.ContentLength diff --git a/pkg/devops/sources/cdn_test.go b/pkg/devops/sources/cdn_test.go index 52996ae..de9c963 100644 --- a/pkg/devops/sources/cdn_test.go +++ b/pkg/devops/sources/cdn_test.go @@ -34,7 +34,7 @@ func TestCDNSource_LatestVersion_Good(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/manifest.json" { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"version": "1.2.3"}`) + _, _ = fmt.Fprint(w, `{"version": "1.2.3"}`) } else { w.WriteHeader(http.StatusNotFound) } @@ -56,7 +56,7 @@ func TestCDNSource_Download_Good(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/test.img" { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, content) + _, _ = fmt.Fprint(w, content) } else { w.WriteHeader(http.StatusNotFound) } @@ -151,7 +151,7 @@ func TestCDNSource_Download_Good_NoProgress(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) w.WriteHeader(http.StatusOK) - fmt.Fprint(w, content) + _, _ = fmt.Fprint(w, content) })) defer server.Close() @@ -180,7 +180,7 @@ func TestCDNSource_Download_Good_LargeFile(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) w.WriteHeader(http.StatusOK) - w.Write(content) + _, _ = w.Write(content) })) defer server.Close() @@ -268,7 +268,7 @@ func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) { content := "test content" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, content) + _, _ = fmt.Fprint(w, content) })) defer server.Close() @@ -302,4 +302,4 @@ func TestSourceConfig_Struct(t *testing.T) { assert.Equal(t, "ghcr.io/owner/image", cfg.RegistryImage) assert.Equal(t, "https://cdn.example.com", cfg.CDNURL) assert.Equal(t, "image.qcow2", cfg.ImageName) -} \ No newline at end of file +} diff --git a/pkg/docs/cmd_docs.go b/pkg/docs/cmd_docs.go index c75687c..c52686d 100644 --- a/pkg/docs/cmd_docs.go +++ b/pkg/docs/cmd_docs.go @@ -8,15 +8,14 @@ import ( // Style and utility aliases from shared var ( - repoNameStyle = cli.RepoStyle - successStyle = cli.SuccessStyle - errorStyle = cli.ErrorStyle - dimStyle = cli.DimStyle - headerStyle = cli.HeaderStyle - confirm = cli.Confirm - docsFoundStyle = cli.SuccessStyle - docsMissingStyle = cli.DimStyle - docsFileStyle = cli.InfoStyle + repoNameStyle = cli.RepoStyle + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + dimStyle = cli.DimStyle + headerStyle = cli.HeaderStyle + confirm = cli.Confirm + docsFoundStyle = cli.SuccessStyle + docsFileStyle = cli.InfoStyle ) var docsCmd = &cli.Command{ diff --git a/pkg/docs/cmd_scan.go b/pkg/docs/cmd_scan.go index 9920b5f..4300d03 100644 --- a/pkg/docs/cmd_scan.go +++ b/pkg/docs/cmd_scan.go @@ -62,7 +62,7 @@ func loadRegistry(registryPath string) (*repos.Registry, string, error) { if wsConfig != nil && wsConfig.PackagesDir != "" && wsConfig.PackagesDir != "./packages" { pkgDir := wsConfig.PackagesDir - + // Expand ~ if strings.HasPrefix(pkgDir, "~/") { home, _ := os.UserHomeDir() diff --git a/pkg/docs/cmd_sync.go b/pkg/docs/cmd_sync.go index de9e731..b4ce488 100644 --- a/pkg/docs/cmd_sync.go +++ b/pkg/docs/cmd_sync.go @@ -152,4 +152,4 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error { cli.Print("\n%s %s\n", successStyle.Render(i18n.T("i18n.done.sync")), i18n.T("cmd.docs.sync.synced_packages", map[string]interface{}{"Count": synced})) return nil -} \ No newline at end of file +} diff --git a/pkg/framework/core/core.go b/pkg/framework/core/core.go index ade5b94..ac9997b 100644 --- a/pkg/framework/core/core.go +++ b/pkg/framework/core/core.go @@ -356,6 +356,7 @@ func (c *Core) Display() Display { return d } +// Core returns self, implementing the CoreProvider interface. func (c *Core) Core() *Core { return c } // Assets returns the embedded filesystem containing the application's assets. diff --git a/pkg/framework/core/e.go b/pkg/framework/core/e.go index fb1a1e4..edd2028 100644 --- a/pkg/framework/core/e.go +++ b/pkg/framework/core/e.go @@ -56,4 +56,4 @@ func (e *Error) Error() string { // Unwrap provides compatibility for Go's errors.Is and errors.As functions. func (e *Error) Unwrap() error { return e.Err -} \ No newline at end of file +} diff --git a/pkg/framework/core/interfaces.go b/pkg/framework/core/interfaces.go index f382cff..5ee7414 100644 --- a/pkg/framework/core/interfaces.go +++ b/pkg/framework/core/interfaces.go @@ -73,8 +73,6 @@ type Stoppable interface { // Core is the central application object that manages services, assets, and communication. type Core struct { - once sync.Once - initErr error App any // GUI runtime (e.g., Wails App) - set by WithApp option assets embed.FS Features *Features diff --git a/pkg/framework/framework.go b/pkg/framework/framework.go index 1ce53fb..7a50a02 100644 --- a/pkg/framework/framework.go +++ b/pkg/framework/framework.go @@ -16,37 +16,37 @@ import ( // Re-export core types for cleaner imports type ( - Core = core.Core - Option = core.Option - Message = core.Message - Query = core.Query - Task = core.Task - QueryHandler = core.QueryHandler - TaskHandler = core.TaskHandler - Startable = core.Startable - Stoppable = core.Stoppable - Config = core.Config - Display = core.Display - WindowOption = core.WindowOption - Features = core.Features - Contract = core.Contract - Error = core.Error + Core = core.Core + Option = core.Option + Message = core.Message + Query = core.Query + Task = core.Task + QueryHandler = core.QueryHandler + TaskHandler = core.TaskHandler + Startable = core.Startable + Stoppable = core.Stoppable + Config = core.Config + Display = core.Display + WindowOption = core.WindowOption + Features = core.Features + Contract = core.Contract + Error = core.Error ServiceRuntime[T any] = core.ServiceRuntime[T] - Runtime = core.Runtime - ServiceFactory = core.ServiceFactory + Runtime = core.Runtime + ServiceFactory = core.ServiceFactory ) // Re-export core functions var ( - New = core.New - WithService = core.WithService - WithName = core.WithName - WithApp = core.WithApp - WithAssets = core.WithAssets - WithServiceLock = core.WithServiceLock - App = core.App - E = core.E - NewRuntime = core.NewRuntime + New = core.New + WithService = core.WithService + WithName = core.WithName + WithApp = core.WithApp + WithAssets = core.WithAssets + WithServiceLock = core.WithServiceLock + App = core.App + E = core.E + NewRuntime = core.NewRuntime NewWithFactories = core.NewWithFactories ) @@ -55,11 +55,12 @@ func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] { return core.NewServiceRuntime(c, opts) } -// Re-export generic functions +// ServiceFor retrieves a typed service from the core container by name. func ServiceFor[T any](c *Core, name string) (T, error) { return core.ServiceFor[T](c, name) } +// MustServiceFor retrieves a typed service or panics if not found. func MustServiceFor[T any](c *Core, name string) T { return core.MustServiceFor[T](c, name) } diff --git a/pkg/git/git.go b/pkg/git/git.go index 0081737..9f5460c 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -249,6 +249,7 @@ type GitError struct { Stderr string } +// Error returns the git error message, preferring stderr output. func (e *GitError) Error() string { // Return just the stderr message, trimmed msg := strings.TrimSpace(e.Stderr) @@ -258,6 +259,7 @@ func (e *GitError) Error() string { return e.Err.Error() } +// Unwrap returns the underlying error for error chain inspection. func (e *GitError) Unwrap() error { return e.Err } diff --git a/pkg/git/service.go b/pkg/git/service.go index 2ed11da..018001a 100644 --- a/pkg/git/service.go +++ b/pkg/git/service.go @@ -70,10 +70,7 @@ func (s *Service) OnStartup(ctx context.Context) error { func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) { switch m := q.(type) { case QueryStatus: - statuses := Status(context.Background(), StatusOptions{ - Paths: m.Paths, - Names: m.Names, - }) + statuses := Status(context.Background(), StatusOptions(m)) s.lastStatus = statuses return statuses, true, nil diff --git a/pkg/gitcmd/cmd_git.go b/pkg/gitcmd/cmd_git.go new file mode 100644 index 0000000..c54d62a --- /dev/null +++ b/pkg/gitcmd/cmd_git.go @@ -0,0 +1,44 @@ +// Package gitcmd provides git workflow commands as a root-level command. +// +// Git Operations: +// - health: Show status across repos +// - commit: Claude-assisted commit message generation +// - push: Push repos with unpushed commits +// - pull: Pull repos that are behind remote +// - work: Combined status, commit, and push workflow +// +// Safe Operations (for AI agents): +// - file-sync: Sync files across repos with auto commit/push +// - apply: Run command across repos with auto commit/push +package gitcmd + +import ( + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/dev" + "github.com/host-uk/core/pkg/i18n" +) + +func init() { + cli.RegisterCommands(AddGitCommands) +} + +// AddGitCommands registers the 'git' command and all subcommands. +func AddGitCommands(root *cli.Command) { + gitCmd := &cli.Command{ + Use: "git", + Short: i18n.T("cmd.git.short"), + Long: i18n.T("cmd.git.long"), + } + root.AddCommand(gitCmd) + + // Import git commands from dev package + dev.AddHealthCommand(gitCmd) // Shows repo status + dev.AddCommitCommand(gitCmd) + dev.AddPushCommand(gitCmd) + dev.AddPullCommand(gitCmd) + dev.AddWorkCommand(gitCmd) + + // Safe operations for AI agents + dev.AddFileSyncCommand(gitCmd) + dev.AddApplyCommand(gitCmd) +} diff --git a/pkg/go/cmd_format.go b/pkg/go/cmd_format.go index 59ce1c3..b8eaedd 100644 --- a/pkg/go/cmd_format.go +++ b/pkg/go/cmd_format.go @@ -1,8 +1,11 @@ package gocmd import ( + "bufio" "os" "os/exec" + "path/filepath" + "strings" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" @@ -12,14 +15,34 @@ 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", + 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") @@ -30,7 +53,7 @@ func addGoFmtCommand(parent *cli.Command) { if !fmtFix && !fmtDiff { fmtArgs = append(fmtArgs, "-l") } - fmtArgs = append(fmtArgs, ".") + fmtArgs = append(fmtArgs, files...) // Try goimports first, fall back to gofmt var execCmd *exec.Cmd @@ -40,6 +63,20 @@ func addGoFmtCommand(parent *cli.Command) { 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() @@ -47,25 +84,85 @@ func addGoFmtCommand(parent *cli.Command) { } fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix")) - fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, "Show diff of changes") - fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, "Check if formatted (exit 1 if not)") + 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) } -var lintFix bool +// 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", + 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 @@ -74,6 +171,7 @@ func addGoLintCommand(parent *cli.Command) { } 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/pkg/go/cmd_qa.go b/pkg/go/cmd_qa.go index b3e4424..2450120 100644 --- a/pkg/go/cmd_qa.go +++ b/pkg/go/cmd_qa.go @@ -2,89 +2,351 @@ package gocmd import ( "context" + "encoding/json" + "fmt" "os" "os/exec" + "strings" "time" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/qa" ) -var qaFix bool +// QA command flags - comprehensive options for all agents +var ( + qaFix bool + qaChanged bool + qaAll bool + qaSkip string + qaOnly string + qaCoverage bool + qaThreshold 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 code quality checks: formatting, vetting, linting, and testing", - RunE: runGoQADefault, + Long: `Run comprehensive code quality checks for Go projects. + +Checks available: fmt, vet, lint, test, race, 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, } - qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, i18n.T("common.flag.fix")) + // 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") - // Subcommands for individual checks - qaCmd.AddCommand(&cli.Command{ - Use: "fmt", - Short: "Check/fix code formatting", - RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"fmt"}) }, - }) + // 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,vuln,sec,bench)") + qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)") - qaCmd.AddCommand(&cli.Command{ - Use: "vet", - Short: "Run go vet", - RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"vet"}) }, - }) + // 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 coverage threshold (0-100), fail if below") + qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)") - qaCmd.AddCommand(&cli.Command{ - Use: "lint", - Short: "Run golangci-lint", - RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"lint"}) }, - }) + // 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") - qaCmd.AddCommand(&cli.Command{ - Use: "test", - Short: "Run tests", - RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"test"}) }, - }) + // 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") - qaCmd.AddCommand(&cli.Command{ - Use: "race", - Short: "Run tests with race detector", - RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"race"}) }, - }) - - qaCmd.AddCommand(&cli.Command{ - Use: "vuln", - Short: "Check for vulnerabilities", - RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"vuln"}) }, - }) - - qaCmd.AddCommand(&cli.Command{ - Use: "sec", - Short: "Run security scanner", - RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"sec"}) }, - }) + // 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", - RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"fmt", "vet", "lint"}) }, + 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 { - return runQAChecks([]string{"fmt", "vet", "lint", "test", "race", "vuln", "sec"}) + 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) } -// runGoQADefault runs the default QA checks (fmt, vet, lint, test) -func runGoQADefault(cmd *cli.Command, args []string) error { - return runQAChecks([]string{"fmt", "vet", "lint", "test"}) +// 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"` + Threshold *float64 `json:"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"` +} + +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 + } + failed++ + + if !qaJSON && !qaQuiet { + cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error()) + if qaVerbose && output != "" { + cli.Text(output) + } + } + + 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 + if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) { + cov, err := runCoverage(ctx, cwd) + if err == nil { + coverageVal = &cov + if !qaJSON && !qaQuiet { + cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Coverage:"), cov) + } + if qaThreshold > 0 && cov < qaThreshold { + failed++ + if !qaJSON && !qaQuiet { + cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n", + cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) + } + } + } + } + + duration := time.Since(startTime).Round(time.Millisecond) + + // JSON output + if qaJSON { + qaResult := QAResult{ + Success: failed == 0, + Duration: duration.String(), + Checks: results, + Coverage: coverageVal, + } + if qaThreshold > 0 { + qaResult.Threshold = &qaThreshold + } + 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 { + os.Exit(1) + } + return nil +} + +func determineChecks() []string { + // If --only is specified, use those + if qaOnly != "" { + return strings.Split(qaOnly, ",") + } + + // Default checks + checks := []string{"fmt", "lint", "test", "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. @@ -94,127 +356,92 @@ type QACheck struct { Args []string } -func runQAChecks(checkNames []string) error { - 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 %s found)", i18n.T("gram.word.go_mod")) - } - - cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA")) - - checks := buildChecksForNames(checkNames) - - ctx := context.Background() - startTime := time.Now() - passed := 0 - failed := 0 - - for _, check := range checks { - cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name)) - - if err := runCheck(ctx, cwd, check); err != nil { - cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error()) - failed++ - } else { - cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass")) - passed++ - } - } - - // Summary - cli.Blank() - duration := time.Since(startTime).Round(time.Millisecond) - - 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) - os.Exit(1) - } - - cli.Print("%s %s (%s)\n", - cli.SuccessStyle.Render(cli.Glyph(":check:")), - i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"), - duration) - - return nil -} - -func buildChecksForNames(names []string) []QACheck { - allChecks := map[string]QACheck{ - "fmt": { - Name: "format", - Command: "gofmt", - Args: fmtArgs(qaFix), - }, - "vet": { - Name: "vet", - Command: "go", - Args: []string{"vet", "./..."}, - }, - "lint": { - Name: "lint", - Command: "golangci-lint", - Args: lintArgs(qaFix), - }, - "test": { - Name: "test", - Command: "go", - Args: []string{"test", "./..."}, - }, - "race": { - Name: "test", - Command: "go", - Args: []string{"test", "-race", "./..."}, - }, - "vuln": { - Name: "scan", - Command: "govulncheck", - Args: []string{"./..."}, - }, - "sec": { - Name: "scan", - Command: "gosec", - Args: []string{"-quiet", "./..."}, - }, - } - +func buildChecks(names []string) []QACheck { var checks []QACheck for _, name := range names { - if check, ok := allChecks[name]; ok { + name = strings.TrimSpace(name) + check := buildCheck(name) + if check.Command != "" { checks = append(checks, check) } } return checks } -func fmtArgs(fix bool) []string { - if fix { - return []string{"-w", "."} +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 "docblock": + // Special internal check - handled separately + return QACheck{Name: "docblock", Command: "_internal_"} + + default: + return QACheck{} } - return []string{"-l", "."} } -func lintArgs(fix bool) []string { - args := []string{"run"} - if fix { - args = append(args, "--fix") +func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) { + // Handle internal checks + if check.Command == "_internal_" { + return runInternalCheck(check) } - args = append(args, "./...") - return args -} -func runCheck(ctx context.Context, dir string, check QACheck) error { // Check if command exists if _, err := exec.LookPath(check.Command); err != nil { - return cli.Err("%s: %s", check.Command, i18n.T("i18n.done.miss")) + return "", cli.Err("%s: not installed", check.Command) } cmd := exec.CommandContext(ctx, check.Command, check.Args...) @@ -224,18 +451,100 @@ func runCheck(ctx context.Context, dir string, check QACheck) error { if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" { output, err := cmd.Output() if err != nil { - return err + return string(output), err } if len(output) > 0 { // Show files that need formatting - cli.Text(string(output)) - return cli.Err("%s (use --fix)", i18n.T("i18n.fail.format", i18n.T("i18n.count.file", len(output)))) + if !qaQuiet && !qaJSON { + cli.Text(string(output)) + } + return string(output), cli.Err("files need formatting (use --fix)") } - return nil + 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() + return "", cmd.Run() +} + +func runCoverage(ctx context.Context, dir string) (float64, error) { + args := []string{"test", "-cover", "-coverprofile=/tmp/coverage.out"} + 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, err + } + + // Parse coverage + coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func=/tmp/coverage.out") + output, err := coverCmd.Output() + if err != nil { + return 0, err + } + + // Parse last line for total coverage + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 0 { + return 0, nil + } + + lastLine := lines[len(lines)-1] + fields := strings.Fields(lastLine) + if len(fields) < 3 { + return 0, nil + } + + // Parse percentage (e.g., "45.6%") + pctStr := strings.TrimSuffix(fields[len(fields)-1], "%") + var pct float64 + if _, err := fmt.Sscanf(pctStr, "%f", &pct); err == nil { + return pct, nil + } + + return 0, nil +} + +// runInternalCheck runs internal Go-based checks (not external commands). +func runInternalCheck(check QACheck) (string, error) { + switch check.Name { + 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/pkg/i18n/compose_test.go b/pkg/i18n/compose_test.go index dffda78..0428bb2 100644 --- a/pkg/i18n/compose_test.go +++ b/pkg/i18n/compose_test.go @@ -797,4 +797,3 @@ func TestProgressSubjectMatchesExpected(t *testing.T) { }) } } - diff --git a/pkg/i18n/handler.go b/pkg/i18n/handler.go index d40df14..6beac1e 100644 --- a/pkg/i18n/handler.go +++ b/pkg/i18n/handler.go @@ -11,10 +11,12 @@ import ( // LabelHandler handles i18n.label.{word} → "Status:" patterns. type LabelHandler struct{} +// Match returns true for keys starting with "i18n.label.". func (h LabelHandler) Match(key string) bool { return strings.HasPrefix(key, "i18n.label.") } +// Handle transforms label keys into formatted labels with colons. func (h LabelHandler) Handle(key string, args []any, next func() string) string { word := strings.TrimPrefix(key, "i18n.label.") return Label(word) @@ -23,10 +25,12 @@ func (h LabelHandler) Handle(key string, args []any, next func() string) string // ProgressHandler handles i18n.progress.{verb} → "Building..." patterns. type ProgressHandler struct{} +// Match returns true for keys starting with "i18n.progress.". func (h ProgressHandler) Match(key string) bool { return strings.HasPrefix(key, "i18n.progress.") } +// Handle transforms progress keys into gerund phrases like "Building...". func (h ProgressHandler) Handle(key string, args []any, next func() string) string { verb := strings.TrimPrefix(key, "i18n.progress.") if len(args) > 0 { @@ -40,10 +44,12 @@ func (h ProgressHandler) Handle(key string, args []any, next func() string) stri // CountHandler handles i18n.count.{noun} → "5 files" patterns. type CountHandler struct{} +// Match returns true for keys starting with "i18n.count.". func (h CountHandler) Match(key string) bool { return strings.HasPrefix(key, "i18n.count.") } +// Handle transforms count keys into pluralized phrases like "5 files". func (h CountHandler) Handle(key string, args []any, next func() string) string { noun := strings.TrimPrefix(key, "i18n.count.") if len(args) > 0 { @@ -56,10 +62,12 @@ func (h CountHandler) Handle(key string, args []any, next func() string) string // DoneHandler handles i18n.done.{verb} → "File deleted" patterns. type DoneHandler struct{} +// Match returns true for keys starting with "i18n.done.". func (h DoneHandler) Match(key string) bool { return strings.HasPrefix(key, "i18n.done.") } +// Handle transforms done keys into past-tense completion messages. func (h DoneHandler) Handle(key string, args []any, next func() string) string { verb := strings.TrimPrefix(key, "i18n.done.") if len(args) > 0 { @@ -73,10 +81,12 @@ func (h DoneHandler) Handle(key string, args []any, next func() string) string { // FailHandler handles i18n.fail.{verb} → "Failed to delete file" patterns. type FailHandler struct{} +// Match returns true for keys starting with "i18n.fail.". func (h FailHandler) Match(key string) bool { return strings.HasPrefix(key, "i18n.fail.") } +// Handle transforms fail keys into failure messages like "Failed to delete". func (h FailHandler) Handle(key string, args []any, next func() string) string { verb := strings.TrimPrefix(key, "i18n.fail.") if len(args) > 0 { @@ -90,10 +100,12 @@ func (h FailHandler) Handle(key string, args []any, next func() string) string { // NumericHandler handles i18n.numeric.{format} → formatted numbers. type NumericHandler struct{} +// Match returns true for keys starting with "i18n.numeric.". func (h NumericHandler) Match(key string) bool { return strings.HasPrefix(key, "i18n.numeric.") } +// Handle transforms numeric keys into locale-formatted numbers. func (h NumericHandler) Handle(key string, args []any, next func() string) string { if len(args) == 0 { return next() diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index 311237c..f328acb 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -170,15 +170,63 @@ "flag.push": "Push container image to registry", "flag.targets": "Comma-separated build targets (e.g., linux/amd64,darwin/arm64)", "flag.type": "Project type override (go, wails, node, php)", + "label.build": "Build", + "label.type": "Type:", + "label.output": "Output:", + "label.binary": "Binary:", + "label.targets": "Targets:", + "label.sign": "Sign", + "label.archive": "Archive", + "label.checksum": "Checksum", + "label.ok": "OK", + "building_project": "Building project", + "built_artifacts": "Built {{.Count}} artifacts", + "signing_binaries": "Signing binaries", + "creating_archives": "Creating archives", + "computing_checksums": "Computing checksums", "error.invalid_target": "invalid target format \"{{.Target}}\", expected OS/arch (e.g., linux/amd64)", "error.no_project_type": "no supported project type detected in {{.Dir}}\nSupported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", + "error.no_targets": "no build targets specified", + "error.signing_failed": "signing failed", + "error.notarization_failed": "notarization failed", + "error.archive_failed": "archive creation failed", + "error.checksum_failed": "checksum generation failed", + "error.gpg_signing_failed": "GPG signing failed", + "error.node_not_implemented": "Node.js builds not yet implemented", + "error.php_not_implemented": "PHP builds not yet implemented", + "error.unsupported_type": "unsupported project type", "from_path.short": "Build from a local directory", + "from_path.flag.path": "Path to application directory", + "from_path.starting": "Building from path:", + "from_path.generating_template": "Generating application template...", + "from_path.copying_files": "Copying application files...", + "from_path.compiling": "Compiling application...", + "from_path.success": "Build complete:", + "from_path.error.invalid_path": "invalid path", + "from_path.error.must_be_directory": "path must be a directory", + "from_path.error.go_mod_tidy": "go mod tidy failed", + "from_path.error.go_build": "go build failed", "pwa.short": "Build from a live PWA URL", + "pwa.flag.url": "URL of the PWA to build", + "pwa.starting": "Building PWA from URL:", + "pwa.downloading_to": "Downloading to:", + "pwa.no_manifest": "No manifest.json found, using defaults", + "pwa.found_manifest": "Found manifest:", + "pwa.download_complete": "Download complete", + "pwa.error.no_manifest_tag": "no manifest link tag found in HTML", "sdk.short": "Generate API SDKs from OpenAPI spec", "sdk.long": "Generate API SDKs from an OpenAPI specification file. Supports multiple languages including TypeScript, Go, and PHP.", "sdk.flag.dry_run": "Show what would be generated without writing files", "sdk.flag.lang": "Target language (typescript, go, php)", - "sdk.flag.version": "SDK version to generate" + "sdk.flag.version": "SDK version to generate", + "sdk.label": "SDK", + "sdk.generating": "Generating SDK", + "sdk.dry_run_mode": "(dry run - no files will be written)", + "sdk.language_label": "Language:", + "sdk.languages_label": "Languages:", + "sdk.would_generate": "Would generate SDK", + "sdk.generated_label": "Generated:", + "sdk.complete": "SDK generation complete" }, "ci": { "short": "Publish releases (dry-run by default)", @@ -298,13 +346,22 @@ "install_macos": "brew install git gh php composer node pnpm docker", "ssh_missing": "SSH key missing - run: ssh-keygen && gh ssh-key add" }, + "git": { + "short": "Git workflow commands", + "long": "Git workflow commands for managing repositories. Includes status, commit, push, pull operations and safe multi-repo commands for AI agents." + }, "go": { "short": "Go development tools", "long": "Go development tools including testing, formatting, linting, and module management.", "test.short": "Run Go tests", "cov.short": "Run tests with coverage report", "fmt.short": "Format Go code", + "fmt.no_changes": "No changed Go files to format.", + "fmt.flag.check": "Check if formatted (exit 1 if not)", + "fmt.flag.all": "Check all files, not just changed ones", "lint.short": "Run golangci-lint", + "lint.no_changes": "No changed Go files to lint.", + "lint.flag.all": "Lint all files, not just changed ones", "install.short": "Install Go binary", "mod.short": "Module management", "work.short": "Workspace management", @@ -497,7 +554,13 @@ "issues.category.triage": "Needs Triage", "issues.hint.needs_response": "commented recently", "issues.hint.blocked": "Waiting on dependency", - "issues.hint.triage": "Add labels and assignee" + "issues.hint.triage": "Add labels and assignee", + "docblock.short": "Check docstring coverage for Go code", + "docblock.long": "Analyse Go packages for docstring coverage on exported symbols. Checks functions, types, constants, and variables.", + "docblock.flag.threshold": "Minimum coverage percentage required", + "docblock.missing_docs": "Missing documentation:", + "docblock.coverage": "Docstring coverage:", + "docblock.use_verbose": "Run with -v to see missing docstrings" }, "test": { "short": "Run Go tests with coverage", @@ -542,6 +605,7 @@ }, "label": { "done": "Done", + "success": "Success", "error": "Error", "warning": "Warning", "info": "Info", diff --git a/pkg/log/log.go b/pkg/log/log.go index d308cfc..ff8e3ba 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -24,11 +24,17 @@ import ( // Level defines logging verbosity. type Level int +// Logging level constants ordered by increasing verbosity. const ( + // LevelQuiet suppresses all log output. LevelQuiet Level = iota + // LevelError shows only error messages. LevelError + // LevelWarn shows warnings and errors. LevelWarn + // LevelInfo shows informational messages, warnings, and errors. LevelInfo + // LevelDebug shows all messages including debug details. LevelDebug ) diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 2b2345b..7ef032f 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -87,37 +87,44 @@ func (s *Service) registerTools() { }, s.getSupportedLanguages) } -// Tool input/output types +// Tool input/output types for MCP file operations. +// ReadFileInput contains parameters for reading a file. type ReadFileInput struct { Path string `json:"path"` } +// ReadFileOutput contains the result of reading a file. type ReadFileOutput struct { Content string `json:"content"` Language string `json:"language"` Path string `json:"path"` } +// WriteFileInput contains parameters for writing a file. type WriteFileInput struct { Path string `json:"path"` Content string `json:"content"` } +// WriteFileOutput contains the result of writing a file. type WriteFileOutput struct { Success bool `json:"success"` Path string `json:"path"` } +// ListDirectoryInput contains parameters for listing a directory. type ListDirectoryInput struct { Path string `json:"path"` } +// ListDirectoryOutput contains the result of listing a directory. type ListDirectoryOutput struct { Entries []DirectoryEntry `json:"entries"` Path string `json:"path"` } +// DirectoryEntry represents a single entry in a directory listing. type DirectoryEntry struct { Name string `json:"name"` Path string `json:"path"` @@ -125,66 +132,80 @@ type DirectoryEntry struct { Size int64 `json:"size"` } +// CreateDirectoryInput contains parameters for creating a directory. type CreateDirectoryInput struct { Path string `json:"path"` } +// CreateDirectoryOutput contains the result of creating a directory. type CreateDirectoryOutput struct { Success bool `json:"success"` Path string `json:"path"` } +// DeleteFileInput contains parameters for deleting a file. type DeleteFileInput struct { Path string `json:"path"` } +// DeleteFileOutput contains the result of deleting a file. type DeleteFileOutput struct { Success bool `json:"success"` Path string `json:"path"` } +// RenameFileInput contains parameters for renaming a file. type RenameFileInput struct { OldPath string `json:"oldPath"` NewPath string `json:"newPath"` } +// RenameFileOutput contains the result of renaming a file. type RenameFileOutput struct { Success bool `json:"success"` OldPath string `json:"oldPath"` NewPath string `json:"newPath"` } +// FileExistsInput contains parameters for checking file existence. type FileExistsInput struct { Path string `json:"path"` } +// FileExistsOutput contains the result of checking file existence. type FileExistsOutput struct { Exists bool `json:"exists"` IsDir bool `json:"isDir"` Path string `json:"path"` } +// DetectLanguageInput contains parameters for detecting file language. type DetectLanguageInput struct { Path string `json:"path"` } +// DetectLanguageOutput contains the detected programming language. type DetectLanguageOutput struct { Language string `json:"language"` Path string `json:"path"` } +// GetSupportedLanguagesInput is an empty struct for the languages query. type GetSupportedLanguagesInput struct{} +// GetSupportedLanguagesOutput contains the list of supported languages. type GetSupportedLanguagesOutput struct { Languages []LanguageInfo `json:"languages"` } +// LanguageInfo describes a supported programming language. type LanguageInfo struct { ID string `json:"id"` Name string `json:"name"` Extensions []string `json:"extensions"` } +// EditDiffInput contains parameters for editing a file via diff. type EditDiffInput struct { Path string `json:"path"` OldString string `json:"old_string"` @@ -192,6 +213,7 @@ type EditDiffInput struct { ReplaceAll bool `json:"replace_all,omitempty"` } +// EditDiffOutput contains the result of a diff-based edit operation. type EditDiffOutput struct { Path string `json:"path"` Success bool `json:"success"` @@ -308,6 +330,10 @@ func (s *Service) getSupportedLanguages(ctx context.Context, req *mcp.CallToolRe } func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input EditDiffInput) (*mcp.CallToolResult, EditDiffOutput, error) { + if input.OldString == "" { + return nil, EditDiffOutput{}, fmt.Errorf("old_string cannot be empty") + } + content, err := os.ReadFile(input.Path) if err != nil { return nil, EditDiffOutput{}, fmt.Errorf("failed to read file: %w", err) diff --git a/pkg/monitor/cmd_monitor.go b/pkg/monitor/cmd_monitor.go index d4821e2..62f4b68 100644 --- a/pkg/monitor/cmd_monitor.go +++ b/pkg/monitor/cmd_monitor.go @@ -32,24 +32,24 @@ var ( // Finding represents a security finding from any source type Finding struct { - Source string `json:"source"` // semgrep, trivy, dependabot, secret-scanning, etc. - Severity string `json:"severity"` // critical, high, medium, low - Rule string `json:"rule"` // Rule ID or CVE - File string `json:"file"` // Affected file path - Line int `json:"line"` // Line number (0 if N/A) - Message string `json:"message"` // Description - URL string `json:"url"` // Link to finding - State string `json:"state"` // open, dismissed, fixed - RepoName string `json:"repo"` // Repository name - CreatedAt string `json:"created_at"` // When found - Labels []string `json:"suggested_labels,omitempty"` + Source string `json:"source"` // semgrep, trivy, dependabot, secret-scanning, etc. + Severity string `json:"severity"` // critical, high, medium, low + Rule string `json:"rule"` // Rule ID or CVE + File string `json:"file"` // Affected file path + Line int `json:"line"` // Line number (0 if N/A) + Message string `json:"message"` // Description + URL string `json:"url"` // Link to finding + State string `json:"state"` // open, dismissed, fixed + RepoName string `json:"repo"` // Repository name + CreatedAt string `json:"created_at"` // When found + Labels []string `json:"suggested_labels,omitempty"` } // CodeScanningAlert represents a GitHub code scanning alert type CodeScanningAlert struct { - Number int `json:"number"` - State string `json:"state"` // open, dismissed, fixed - Rule struct { + Number int `json:"number"` + State string `json:"state"` // open, dismissed, fixed + Rule struct { ID string `json:"id"` Severity string `json:"severity"` Description string `json:"description"` @@ -72,8 +72,8 @@ type CodeScanningAlert struct { // DependabotAlert represents a GitHub Dependabot alert type DependabotAlert struct { - Number int `json:"number"` - State string `json:"state"` // open, dismissed, fixed + Number int `json:"number"` + State string `json:"state"` // open, dismissed, fixed SecurityVulnerability struct { Severity string `json:"severity"` Package struct { @@ -373,17 +373,17 @@ func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) { continue } f := Finding{ - Source: "secret-scanning", - Severity: "critical", // Secrets are always critical - Rule: alert.SecretType, - File: alert.LocationType, - Line: 0, - Message: fmt.Sprintf("Exposed %s detected", alert.SecretType), - URL: alert.HTMLURL, - State: alert.State, - RepoName: repoName, + Source: "secret-scanning", + Severity: "critical", // Secrets are always critical + Rule: alert.SecretType, + File: alert.LocationType, + Line: 0, + Message: fmt.Sprintf("Exposed %s detected", alert.SecretType), + URL: alert.HTMLURL, + State: alert.State, + RepoName: repoName, CreatedAt: alert.CreatedAt, - Labels: []string{"type:security", "secrets"}, + Labels: []string{"type:security", "secrets"}, } findings = append(findings, f) } diff --git a/pkg/php/cmd.go b/pkg/php/cmd.go index 0f72bd9..f2cd2d8 100644 --- a/pkg/php/cmd.go +++ b/pkg/php/cmd.go @@ -105,7 +105,6 @@ func AddPHPCommands(root *cobra.Command) { } root.AddCommand(phpCmd) - // Development addPHPDevCommand(phpCmd) addPHPLogsCommand(phpCmd) @@ -139,4 +138,4 @@ func AddPHPCommands(root *cobra.Command) { // Deployment addPHPDeployCommands(phpCmd) -} \ No newline at end of file +} diff --git a/pkg/php/cmd_ci.go b/pkg/php/cmd_ci.go index 3550b12..445e5e4 100644 --- a/pkg/php/cmd_ci.go +++ b/pkg/php/cmd_ci.go @@ -37,13 +37,13 @@ var ( // CIResult represents the overall CI pipeline result type CIResult struct { - Passed bool `json:"passed"` - ExitCode int `json:"exit_code"` - Duration string `json:"duration"` - StartedAt time.Time `json:"started_at"` + Passed bool `json:"passed"` + ExitCode int `json:"exit_code"` + Duration string `json:"duration"` + StartedAt time.Time `json:"started_at"` Checks []CICheckResult `json:"checks"` - Summary CISummary `json:"summary"` - Artifacts []string `json:"artifacts,omitempty"` + Summary CISummary `json:"summary"` + Artifacts []string `json:"artifacts,omitempty"` } // CICheckResult represents an individual check result @@ -100,9 +100,9 @@ func runPHPCI() error { // Define checks to run in order checks := []struct { - name string - run func(context.Context, string) (CICheckResult, error) - sarif bool // Whether this check can generate SARIF + name string + run func(context.Context, string) (CICheckResult, error) + sarif bool // Whether this check can generate SARIF }{ {"test", runCITest, false}, {"stan", runCIStan, true}, @@ -135,8 +135,8 @@ func runPHPCI() error { checkResult, err := check.run(ctx, cwd) if err != nil { checkResult = CICheckResult{ - Name: check.name, - Status: "failed", + Name: check.name, + Status: "failed", Details: err.Error(), } } @@ -244,8 +244,8 @@ func runCITest(ctx context.Context, dir string) (CICheckResult, error) { result := CICheckResult{Name: "test", Status: "passed"} opts := TestOptions{ - Dir: dir, - Output: nil, // Suppress output + Dir: dir, + Output: nil, // Suppress output } if err := RunTests(ctx, opts); err != nil { diff --git a/pkg/php/cmd_dev.go b/pkg/php/cmd_dev.go index ebca16d..d22345c 100644 --- a/pkg/php/cmd_dev.go +++ b/pkg/php/cmd_dev.go @@ -10,7 +10,6 @@ import ( "syscall" "time" - "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" "github.com/spf13/cobra" @@ -157,7 +156,7 @@ func runPHPDev(opts phpDevOptions) error { if err != nil { cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs")) } else { - defer logsReader.Close() + defer func() { _ = logsReader.Close() }() scanner := bufio.NewScanner(logsReader) for scanner.Scan() { diff --git a/pkg/php/container.go b/pkg/php/container.go index 37a1d73..9b8f630 100644 --- a/pkg/php/container.go +++ b/pkg/php/container.go @@ -132,7 +132,7 @@ func BuildDocker(ctx context.Context, opts DockerBuildOptions) error { if err := os.WriteFile(tempDockerfile, []byte(content), 0644); err != nil { return cli.WrapVerb(err, "write", "Dockerfile") } - defer os.Remove(tempDockerfile) + defer func() { _ = os.Remove(tempDockerfile) }() dockerfilePath = tempDockerfile } @@ -233,7 +233,7 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error { if err := os.WriteFile(tempYAML, []byte(content), 0644); err != nil { return cli.WrapVerb(err, "write", "template") } - defer os.Remove(tempYAML) + defer func() { _ = os.Remove(tempYAML) }() // Build LinuxKit image args := []string{ diff --git a/pkg/php/container_test.go b/pkg/php/container_test.go index f1a2c5c..71e9e51 100644 --- a/pkg/php/container_test.go +++ b/pkg/php/container_test.go @@ -1,6 +1,7 @@ package php import ( + "context" "os" "path/filepath" "testing" @@ -106,12 +107,12 @@ func TestLookupLinuxKit_Bad(t *testing.T) { origPath := os.Getenv("PATH") origCommonPaths := commonLinuxKitPaths defer func() { - os.Setenv("PATH", origPath) + _ = os.Setenv("PATH", origPath) commonLinuxKitPaths = origCommonPaths }() // Set PATH to empty and clear common paths - os.Setenv("PATH", "") + _ = os.Setenv("PATH", "") commonLinuxKitPaths = []string{} _, err := lookupLinuxKit() @@ -202,7 +203,7 @@ func TestBuildDocker_Bad(t *testing.T) { t.Run("fails for non-PHP project", func(t *testing.T) { dir := t.TempDir() - err := BuildDocker(nil, DockerBuildOptions{ProjectDir: dir}) + err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir}) assert.Error(t, err) assert.Contains(t, err.Error(), "not a PHP project") }) @@ -213,7 +214,7 @@ func TestBuildLinuxKit_Bad(t *testing.T) { t.Run("fails for non-PHP project", func(t *testing.T) { dir := t.TempDir() - err := BuildLinuxKit(nil, LinuxKitBuildOptions{ProjectDir: dir}) + err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir}) assert.Error(t, err) assert.Contains(t, err.Error(), "not a PHP project") }) @@ -221,7 +222,7 @@ func TestBuildLinuxKit_Bad(t *testing.T) { func TestServeProduction_Bad(t *testing.T) { t.Run("fails without image name", func(t *testing.T) { - err := ServeProduction(nil, ServeOptions{}) + err := ServeProduction(context.TODO(), ServeOptions{}) assert.Error(t, err) assert.Contains(t, err.Error(), "image name is required") }) diff --git a/pkg/php/coolify.go b/pkg/php/coolify.go index fe2e59b..76aa4ca 100644 --- a/pkg/php/coolify.go +++ b/pkg/php/coolify.go @@ -92,7 +92,7 @@ func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) { } return nil, cli.WrapVerb(err, "open", ".env file") } - defer file.Close() + defer func() { _ = file.Close() }() content, err := io.ReadAll(file) if err != nil { @@ -177,7 +177,7 @@ func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force b if err != nil { return nil, cli.Wrap(err, "request failed") } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { return nil, c.parseError(resp) @@ -210,7 +210,7 @@ func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID s if err != nil { return nil, cli.Wrap(err, "request failed") } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, c.parseError(resp) @@ -242,7 +242,7 @@ func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit if err != nil { return nil, cli.Wrap(err, "request failed") } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, c.parseError(resp) @@ -280,7 +280,7 @@ func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string if err != nil { return nil, cli.Wrap(err, "request failed") } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { return nil, c.parseError(resp) @@ -312,7 +312,7 @@ func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp, if err != nil { return nil, cli.Wrap(err, "request failed") } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, c.parseError(resp) diff --git a/pkg/php/deploy_test.go b/pkg/php/deploy_test.go index 436d457..228de7d 100644 --- a/pkg/php/deploy_test.go +++ b/pkg/php/deploy_test.go @@ -8,11 +8,11 @@ import ( func TestLoadCoolifyConfig_Good(t *testing.T) { tests := []struct { - name string - envContent string - wantURL string - wantToken string - wantAppID string + name string + envContent string + wantURL string + wantToken string + wantAppID string wantStaging string }{ { @@ -139,9 +139,9 @@ func TestGetAppIDForEnvironment_Good(t *testing.T) { } tests := []struct { - name string - env Environment - wantID string + name string + env Environment + wantID string }{ { name: "production environment", diff --git a/pkg/php/detect.go b/pkg/php/detect.go index 3afc0b5..5ba153f 100644 --- a/pkg/php/detect.go +++ b/pkg/php/detect.go @@ -11,12 +11,18 @@ import ( // DetectedService represents a service that was detected in a Laravel project. type DetectedService string +// Detected service constants for Laravel projects. const ( + // ServiceFrankenPHP indicates FrankenPHP server is detected. ServiceFrankenPHP DetectedService = "frankenphp" - ServiceVite DetectedService = "vite" - ServiceHorizon DetectedService = "horizon" - ServiceReverb DetectedService = "reverb" - ServiceRedis DetectedService = "redis" + // ServiceVite indicates Vite frontend bundler is detected. + ServiceVite DetectedService = "vite" + // ServiceHorizon indicates Laravel Horizon queue dashboard is detected. + ServiceHorizon DetectedService = "horizon" + // ServiceReverb indicates Laravel Reverb WebSocket server is detected. + ServiceReverb DetectedService = "reverb" + // ServiceRedis indicates Redis cache/queue backend is detected. + ServiceRedis DetectedService = "redis" ) // IsLaravelProject checks if the given directory is a Laravel project. diff --git a/pkg/php/quality.go b/pkg/php/quality.go index 0f27247..8f9109f 100644 --- a/pkg/php/quality.go +++ b/pkg/php/quality.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" ) // FormatOptions configures PHP code formatting. @@ -60,15 +61,20 @@ type AnalyseOptions struct { // FormatterType represents the detected formatter. type FormatterType string +// Formatter type constants. const ( + // FormatterPint indicates Laravel Pint code formatter. FormatterPint FormatterType = "pint" ) // AnalyserType represents the detected static analyser. type AnalyserType string +// Static analyser type constants. const ( - AnalyserPHPStan AnalyserType = "phpstan" + // AnalyserPHPStan indicates standard PHPStan analyser. + AnalyserPHPStan AnalyserType = "phpstan" + // AnalyserLarastan indicates Laravel-specific Larastan analyser. AnalyserLarastan AnalyserType = "larastan" ) @@ -279,7 +285,9 @@ type PsalmOptions struct { // PsalmType represents the detected Psalm configuration. type PsalmType string +// Psalm configuration type constants. const ( + // PsalmStandard indicates standard Psalm configuration. PsalmStandard PsalmType = "psalm" ) @@ -695,10 +703,14 @@ type QAOptions struct { // QAStage represents a stage in the QA pipeline. type QAStage string +// QA pipeline stage constants. const ( - QAStageQuick QAStage = "quick" + // QAStageQuick runs fast checks only (audit, fmt, stan). + QAStageQuick QAStage = "quick" + // QAStageStandard runs standard checks including tests. QAStageStandard QAStage = "standard" - QAStageFull QAStage = "full" + // QAStageFull runs all checks including slow security scans. + QAStageFull QAStage = "full" ) // QACheckResult holds the result of a single QA check. @@ -816,7 +828,7 @@ func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResu for _, audit := range auditResults { check := SecurityCheck{ ID: audit.Tool + "_audit", - Name: strings.Title(audit.Tool) + " Security Audit", + Name: i18n.Title(audit.Tool) + " Security Audit", Description: "Check " + audit.Tool + " dependencies for vulnerabilities", Severity: "critical", Passed: audit.Vulnerabilities == 0 && audit.Error == nil, diff --git a/pkg/php/services.go b/pkg/php/services.go index 47a8b78..aa1c969 100644 --- a/pkg/php/services.go +++ b/pkg/php/services.go @@ -220,6 +220,7 @@ type FrankenPHPOptions struct { KeyFile string } +// Start launches the FrankenPHP Octane server. func (s *FrankenPHPService) Start(ctx context.Context) error { args := []string{ "artisan", "octane:start", @@ -239,6 +240,7 @@ func (s *FrankenPHPService) Start(ctx context.Context) error { return s.startProcess(ctx, "php", args, nil) } +// Stop terminates the FrankenPHP server process. func (s *FrankenPHPService) Stop() error { return s.stopProcess() } @@ -277,6 +279,7 @@ type ViteOptions struct { PackageManager string } +// Start launches the Vite development server. func (s *ViteService) Start(ctx context.Context) error { var cmdName string var args []string @@ -299,6 +302,7 @@ func (s *ViteService) Start(ctx context.Context) error { return s.startProcess(ctx, cmdName, args, nil) } +// Stop terminates the Vite development server. func (s *ViteService) Stop() error { return s.stopProcess() } @@ -319,10 +323,12 @@ func NewHorizonService(dir string) *HorizonService { } } +// Start launches the Laravel Horizon queue worker. func (s *HorizonService) Start(ctx context.Context) error { return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil) } +// Stop terminates Horizon using its terminate command. func (s *HorizonService) Stop() error { // Horizon has its own terminate command cmd := exec.Command("php", "artisan", "horizon:terminate") @@ -358,6 +364,7 @@ type ReverbOptions struct { Port int } +// Start launches the Laravel Reverb WebSocket server. func (s *ReverbService) Start(ctx context.Context) error { args := []string{ "artisan", "reverb:start", @@ -367,6 +374,7 @@ func (s *ReverbService) Start(ctx context.Context) error { return s.startProcess(ctx, "php", args, nil) } +// Stop terminates the Reverb WebSocket server. func (s *ReverbService) Stop() error { return s.stopProcess() } @@ -400,6 +408,7 @@ type RedisOptions struct { ConfigFile string } +// Start launches the Redis server. func (s *RedisService) Start(ctx context.Context) error { args := []string{ "--port", cli.Sprintf("%d", s.port), @@ -414,6 +423,7 @@ func (s *RedisService) Start(ctx context.Context) error { return s.startProcess(ctx, "redis-server", args, nil) } +// Stop terminates Redis using the shutdown command. func (s *RedisService) Stop() error { // Try graceful shutdown via redis-cli cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave") diff --git a/pkg/php/testing.go b/pkg/php/testing.go index 0c8a9a6..7a5ebbb 100644 --- a/pkg/php/testing.go +++ b/pkg/php/testing.go @@ -40,8 +40,11 @@ type TestOptions struct { // TestRunner represents the detected test runner. type TestRunner string +// Test runner type constants. const ( - TestRunnerPest TestRunner = "pest" + // TestRunnerPest indicates Pest testing framework. + TestRunnerPest TestRunner = "pest" + // TestRunnerPHPUnit indicates PHPUnit testing framework. TestRunnerPHPUnit TestRunner = "phpunit" ) diff --git a/pkg/pkgcmd/cmd_pkg.go b/pkg/pkgcmd/cmd_pkg.go index baf2967..5f6da91 100644 --- a/pkg/pkgcmd/cmd_pkg.go +++ b/pkg/pkgcmd/cmd_pkg.go @@ -13,7 +13,7 @@ func init() { // Style and utility aliases var ( - repoNameStyle = cli.RepoStyle + repoNameStyle = cli.RepoStyle successStyle = cli.SuccessStyle errorStyle = cli.ErrorStyle dimStyle = cli.DimStyle diff --git a/pkg/process/process.go b/pkg/process/process.go index a70d391..45ee0d9 100644 --- a/pkg/process/process.go +++ b/pkg/process/process.go @@ -165,18 +165,3 @@ func (p *Process) CloseStdin() error { p.stdin = nil return err } - -// setStatus updates the process status (internal use). -func (p *Process) setStatus(status Status) { - p.mu.Lock() - defer p.mu.Unlock() - p.Status = status -} - -// setExitCode sets the exit code and duration (internal use). -func (p *Process) setExitCode(code int, duration time.Duration) { - p.mu.Lock() - defer p.mu.Unlock() - p.ExitCode = code - p.Duration = duration -} diff --git a/pkg/process/process_global.go b/pkg/process/process_global.go index 9a0ffc8..155dedc 100644 --- a/pkg/process/process_global.go +++ b/pkg/process/process_global.go @@ -127,6 +127,7 @@ type ServiceError struct { msg string } +// Error returns the service error message. func (e *ServiceError) Error() string { return e.msg } diff --git a/pkg/process/service_test.go b/pkg/process/service_test.go index dba9d82..af5a014 100644 --- a/pkg/process/service_test.go +++ b/pkg/process/service_test.go @@ -255,4 +255,3 @@ func TestService_Clear(t *testing.T) { assert.Len(t, svc.List(), 0) }) } - diff --git a/pkg/qa/cmd_docblock.go b/pkg/qa/cmd_docblock.go new file mode 100644 index 0000000..357e1b6 --- /dev/null +++ b/pkg/qa/cmd_docblock.go @@ -0,0 +1,353 @@ +// cmd_docblock.go implements docblock/docstring coverage checking for Go code. +// +// Usage: +// +// core qa docblock # Check current directory +// core qa docblock ./pkg/... # Check specific packages +// core qa docblock --threshold=80 # Require 80% coverage +package qa + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" +) + +// Docblock command flags +var ( + docblockThreshold float64 + docblockVerbose bool + docblockJSON bool +) + +// addDocblockCommand adds the 'docblock' command to qa. +func addDocblockCommand(parent *cli.Command) { + docblockCmd := &cli.Command{ + Use: "docblock [packages...]", + Short: i18n.T("cmd.qa.docblock.short"), + Long: i18n.T("cmd.qa.docblock.long"), + RunE: func(cmd *cli.Command, args []string) error { + paths := args + if len(paths) == 0 { + paths = []string{"./..."} + } + return RunDocblockCheck(paths, docblockThreshold, docblockVerbose, docblockJSON) + }, + } + + docblockCmd.Flags().Float64Var(&docblockThreshold, "threshold", 80, i18n.T("cmd.qa.docblock.flag.threshold")) + docblockCmd.Flags().BoolVarP(&docblockVerbose, "verbose", "v", false, i18n.T("common.flag.verbose")) + docblockCmd.Flags().BoolVar(&docblockJSON, "json", false, i18n.T("common.flag.json")) + + parent.AddCommand(docblockCmd) +} + +// DocblockResult holds the result of a docblock coverage check. +type DocblockResult struct { + Coverage float64 `json:"coverage"` + Threshold float64 `json:"threshold"` + Total int `json:"total"` + Documented int `json:"documented"` + Missing []MissingDocblock `json:"missing,omitempty"` + Passed bool `json:"passed"` +} + +// MissingDocblock represents an exported symbol without documentation. +type MissingDocblock struct { + File string `json:"file"` + Line int `json:"line"` + Name string `json:"name"` + Kind string `json:"kind"` // func, type, const, var + Reason string `json:"reason,omitempty"` +} + +// RunDocblockCheck checks docblock coverage for the given packages. +func RunDocblockCheck(paths []string, threshold float64, verbose, jsonOutput bool) error { + result, err := CheckDocblockCoverage(paths) + if err != nil { + return err + } + result.Threshold = threshold + result.Passed = result.Coverage >= threshold + + if jsonOutput { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + if !result.Passed { + return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold) + } + return nil + } + + // Sort missing by file then line + sort.Slice(result.Missing, func(i, j int) bool { + if result.Missing[i].File != result.Missing[j].File { + return result.Missing[i].File < result.Missing[j].File + } + return result.Missing[i].Line < result.Missing[j].Line + }) + + // Print result + if verbose && len(result.Missing) > 0 { + cli.Print("%s\n\n", i18n.T("cmd.qa.docblock.missing_docs")) + for _, m := range result.Missing { + cli.Print(" %s:%d: %s %s\n", + dimStyle.Render(m.File), + m.Line, + dimStyle.Render(m.Kind), + m.Name, + ) + } + cli.Blank() + } + + // Summary + coverageStr := fmt.Sprintf("%.1f%%", result.Coverage) + thresholdStr := fmt.Sprintf("%.1f%%", threshold) + + if result.Passed { + cli.Print("%s %s %s/%s (%s >= %s)\n", + successStyle.Render(i18n.T("common.label.success")), + i18n.T("cmd.qa.docblock.coverage"), + fmt.Sprintf("%d", result.Documented), + fmt.Sprintf("%d", result.Total), + successStyle.Render(coverageStr), + thresholdStr, + ) + return nil + } + + cli.Print("%s %s %s/%s (%s < %s)\n", + errorStyle.Render(i18n.T("common.label.error")), + i18n.T("cmd.qa.docblock.coverage"), + fmt.Sprintf("%d", result.Documented), + fmt.Sprintf("%d", result.Total), + errorStyle.Render(coverageStr), + thresholdStr, + ) + + // Always show compact file:line list when failing (token-efficient for AI agents) + if len(result.Missing) > 0 { + cli.Blank() + for _, m := range result.Missing { + cli.Print("%s:%d\n", m.File, m.Line) + } + } + + return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold) +} + +// CheckDocblockCoverage analyzes Go packages for docblock coverage. +func CheckDocblockCoverage(patterns []string) (*DocblockResult, error) { + result := &DocblockResult{} + + // Expand patterns to actual directories + dirs, err := expandPatterns(patterns) + if err != nil { + return nil, err + } + + fset := token.NewFileSet() + + for _, dir := range dirs { + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, parser.ParseComments) + if err != nil { + // Log parse errors but continue to check other directories + fmt.Fprintf(os.Stderr, "warning: failed to parse %s: %v\n", dir, err) + continue + } + + for _, pkg := range pkgs { + for filename, file := range pkg.Files { + checkFile(fset, filename, file, result) + } + } + } + + if result.Total > 0 { + result.Coverage = float64(result.Documented) / float64(result.Total) * 100 + } + + return result, nil +} + +// expandPatterns expands Go package patterns like ./... to actual directories. +func expandPatterns(patterns []string) ([]string, error) { + var dirs []string + seen := make(map[string]bool) + + for _, pattern := range patterns { + if strings.HasSuffix(pattern, "/...") { + // Recursive pattern + base := strings.TrimSuffix(pattern, "/...") + if base == "." { + base = "." + } + err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + if !info.IsDir() { + return nil + } + // Skip vendor, testdata, and hidden directories (but not "." itself) + name := info.Name() + if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") { + return filepath.SkipDir + } + // Check if directory has Go files + if hasGoFiles(path) && !seen[path] { + dirs = append(dirs, path) + seen[path] = true + } + return nil + }) + if err != nil { + return nil, err + } + } else { + // Single directory + path := pattern + if !seen[path] && hasGoFiles(path) { + dirs = append(dirs, path) + seen[path] = true + } + } + } + + return dirs, nil +} + +// hasGoFiles checks if a directory contains Go files. +func hasGoFiles(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") { + return true + } + } + return false +} + +// checkFile analyzes a single file for docblock coverage. +func checkFile(fset *token.FileSet, filename string, file *ast.File, result *DocblockResult) { + // Make filename relative if possible + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, filename); err == nil { + filename = rel + } + } + + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + // Skip unexported functions + if !ast.IsExported(d.Name.Name) { + continue + } + // Skip methods on unexported types + if d.Recv != nil && len(d.Recv.List) > 0 { + if recvType := getReceiverTypeName(d.Recv.List[0].Type); recvType != "" && !ast.IsExported(recvType) { + continue + } + } + + result.Total++ + if d.Doc != nil && len(d.Doc.List) > 0 { + result.Documented++ + } else { + pos := fset.Position(d.Pos()) + result.Missing = append(result.Missing, MissingDocblock{ + File: filename, + Line: pos.Line, + Name: d.Name.Name, + Kind: "func", + }) + } + + case *ast.GenDecl: + for _, spec := range d.Specs { + switch s := spec.(type) { + case *ast.TypeSpec: + if !ast.IsExported(s.Name.Name) { + continue + } + result.Total++ + // Type can have doc on GenDecl or TypeSpec + if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) { + result.Documented++ + } else { + pos := fset.Position(s.Pos()) + result.Missing = append(result.Missing, MissingDocblock{ + File: filename, + Line: pos.Line, + Name: s.Name.Name, + Kind: "type", + }) + } + + case *ast.ValueSpec: + // Check exported consts and vars + for _, name := range s.Names { + if !ast.IsExported(name.Name) { + continue + } + result.Total++ + // Value can have doc on GenDecl or ValueSpec + if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) { + result.Documented++ + } else { + pos := fset.Position(name.Pos()) + result.Missing = append(result.Missing, MissingDocblock{ + File: filename, + Line: pos.Line, + Name: name.Name, + Kind: kindFromToken(d.Tok), + }) + } + } + } + } + } + } +} + +// getReceiverTypeName extracts the type name from a method receiver. +func getReceiverTypeName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return getReceiverTypeName(t.X) + } + return "" +} + +// kindFromToken returns a string representation of the token kind. +func kindFromToken(tok token.Token) string { + switch tok { + case token.CONST: + return "const" + case token.VAR: + return "var" + default: + return "value" + } +} diff --git a/pkg/qa/cmd_health.go b/pkg/qa/cmd_health.go index 1a3d4b1..95dca54 100644 --- a/pkg/qa/cmd_health.go +++ b/pkg/qa/cmd_health.go @@ -36,10 +36,10 @@ type HealthWorkflowRun struct { // RepoHealth represents the CI health of a single repo type RepoHealth struct { - Name string - Status string // "passing", "failing", "pending", "no_ci", "disabled" - Message string - URL string + Name string + Status string // "passing", "failing", "pending", "no_ci", "disabled" + Message string + URL string FailingSince string } diff --git a/pkg/qa/cmd_qa.go b/pkg/qa/cmd_qa.go index 9d69911..150696c 100644 --- a/pkg/qa/cmd_qa.go +++ b/pkg/qa/cmd_qa.go @@ -41,4 +41,5 @@ func AddQACommands(root *cli.Command) { addReviewCommand(qaCmd) addHealthCommand(qaCmd) addIssuesCommand(qaCmd) + addDocblockCommand(qaCmd) } diff --git a/pkg/release/publishers/chocolatey.go b/pkg/release/publishers/chocolatey.go index 060bed6..9c58d2d 100644 --- a/pkg/release/publishers/chocolatey.go +++ b/pkg/release/publishers/chocolatey.go @@ -13,6 +13,7 @@ import ( "text/template" "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/i18n" ) //go:embed templates/chocolatey/*.tmpl templates/chocolatey/tools/*.tmpl @@ -79,7 +80,7 @@ func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pub data := chocolateyTemplateData{ PackageName: packageName, - Title: fmt.Sprintf("%s CLI", strings.Title(projectName)), + Title: fmt.Sprintf("%s CLI", i18n.Title(projectName)), Description: fmt.Sprintf("%s CLI", projectName), Repository: repo, Version: version, diff --git a/pkg/release/publishers/npm.go b/pkg/release/publishers/npm.go index 9718698..959dd1d 100644 --- a/pkg/release/publishers/npm.go +++ b/pkg/release/publishers/npm.go @@ -204,7 +204,7 @@ func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData, } // Create .npmrc with token - npmrc := fmt.Sprintf("//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n") + npmrc := "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n" if err := os.WriteFile(filepath.Join(tmpDir, ".npmrc"), []byte(npmrc), 0600); err != nil { return fmt.Errorf("npm.Publish: failed to write .npmrc: %w", err) } diff --git a/pkg/release/version_test.go b/pkg/release/version_test.go index a829929..b170a98 100644 --- a/pkg/release/version_test.go +++ b/pkg/release/version_test.go @@ -385,15 +385,15 @@ func TestParseVersion_Good(t *testing.T) { major: 1, minor: 2, patch: 3, }, { - name: "with prerelease", - input: "v1.2.3-alpha", - major: 1, minor: 2, patch: 3, + name: "with prerelease", + input: "v1.2.3-alpha", + major: 1, minor: 2, patch: 3, prerelease: "alpha", }, { - name: "with prerelease and build", - input: "v1.2.3-beta.1+build.456", - major: 1, minor: 2, patch: 3, + name: "with prerelease and build", + input: "v1.2.3-beta.1+build.456", + major: 1, minor: 2, patch: 3, prerelease: "beta.1", build: "build.456", }, diff --git a/pkg/repos/registry.go b/pkg/repos/registry.go index 3ae5d8c..a13abdb 100644 --- a/pkg/repos/registry.go +++ b/pkg/repos/registry.go @@ -14,11 +14,11 @@ import ( // Registry represents a collection of repositories defined in repos.yaml. type Registry struct { - Version int `yaml:"version"` - Org string `yaml:"org"` - BasePath string `yaml:"base_path"` - Repos map[string]*Repo `yaml:"repos"` - Defaults RegistryDefaults `yaml:"defaults"` + Version int `yaml:"version"` + Org string `yaml:"org"` + BasePath string `yaml:"base_path"` + Repos map[string]*Repo `yaml:"repos"` + Defaults RegistryDefaults `yaml:"defaults"` } // RegistryDefaults contains default values applied to all repos. @@ -31,11 +31,16 @@ type RegistryDefaults struct { // RepoType indicates the role of a repository in the ecosystem. type RepoType string +// Repository type constants for ecosystem classification. const ( + // RepoTypeFoundation indicates core foundation packages. RepoTypeFoundation RepoType = "foundation" - RepoTypeModule RepoType = "module" - RepoTypeProduct RepoType = "product" - RepoTypeTemplate RepoType = "template" + // RepoTypeModule indicates reusable module packages. + RepoTypeModule RepoType = "module" + // RepoTypeProduct indicates end-user product applications. + RepoTypeProduct RepoType = "product" + // RepoTypeTemplate indicates starter templates. + RepoTypeTemplate RepoType = "template" ) // Repo represents a single repository in the registry. @@ -219,7 +224,7 @@ func detectOrg(repoPath string) string { func (r *Registry) List() []*Repo { repos := make([]*Repo, 0, len(r.Repos)) for _, repo := range r.Repos { - repos = repos + repos = append(repos, repo) } return repos @@ -308,4 +313,4 @@ func expandPath(path string) string { return filepath.Join(home, path[2:]) } return path -} \ No newline at end of file +} diff --git a/pkg/sdk/detect_test.go b/pkg/sdk/detect_test.go index 15aa89f..4511e08 100644 --- a/pkg/sdk/detect_test.go +++ b/pkg/sdk/detect_test.go @@ -78,10 +78,10 @@ func TestDetectScramble_Bad(t *testing.T) { tmpDir := t.TempDir() err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{}`), 0644) require.NoError(t, err) - + sdk := New(tmpDir, nil) _, err = sdk.detectScramble() assert.Error(t, err) assert.Contains(t, err.Error(), "scramble not found") }) -} \ No newline at end of file +} diff --git a/pkg/sdk/sdk_test.go b/pkg/sdk/sdk_test.go index 02b3db7..ced3b91 100644 --- a/pkg/sdk/sdk_test.go +++ b/pkg/sdk/sdk_test.go @@ -64,8 +64,6 @@ func TestSDK_GenerateLanguage_Bad(t *testing.T) { require.NoError(t, err) - - s := New(tmpDir, nil) err = s.GenerateLanguage(context.Background(), "invalid-lang") diff --git a/pkg/security/cmd_deps.go b/pkg/security/cmd_deps.go index 0d13a93..9637ba6 100644 --- a/pkg/security/cmd_deps.go +++ b/pkg/security/cmd_deps.go @@ -28,15 +28,15 @@ func addDepsCommand(parent *cli.Command) { // DepAlert represents a dependency vulnerability for output. type DepAlert struct { - Repo string `json:"repo"` - Severity string `json:"severity"` - CVE string `json:"cve"` - Package string `json:"package"` - Ecosystem string `json:"ecosystem"` - Vulnerable string `json:"vulnerable_range"` + Repo string `json:"repo"` + Severity string `json:"severity"` + CVE string `json:"cve"` + Package string `json:"package"` + Ecosystem string `json:"ecosystem"` + Vulnerable string `json:"vulnerable_range"` PatchedVersion string `json:"patched_version,omitempty"` - Manifest string `json:"manifest"` - Summary string `json:"summary"` + Manifest string `json:"manifest"` + Summary string `json:"summary"` } func runDeps() error { diff --git a/pkg/security/cmd_security.go b/pkg/security/cmd_security.go index 069bc63..c37d4a0 100644 --- a/pkg/security/cmd_security.go +++ b/pkg/security/cmd_security.go @@ -70,9 +70,9 @@ type CodeScanningAlert struct { State string `json:"state"` DismissedReason string `json:"dismissed_reason"` Rule struct { - ID string `json:"id"` - Severity string `json:"severity"` - Description string `json:"description"` + ID string `json:"id"` + Severity string `json:"severity"` + Description string `json:"description"` Tags []string `json:"tags"` } `json:"rule"` Tool struct { @@ -93,12 +93,12 @@ type CodeScanningAlert struct { // SecretScanningAlert represents a secret scanning alert. type SecretScanningAlert struct { - Number int `json:"number"` - State string `json:"state"` - SecretType string `json:"secret_type"` - Secret string `json:"secret"` - PushProtection bool `json:"push_protection_bypassed"` - Resolution string `json:"resolution"` + Number int `json:"number"` + State string `json:"state"` + SecretType string `json:"secret_type"` + Secret string `json:"secret"` + PushProtection bool `json:"push_protection_bypassed"` + Resolution string `json:"resolution"` } // loadRegistry loads the repository registry. @@ -150,22 +150,6 @@ func runGHAPI(endpoint string) ([]byte, error) { return output, nil } -// severityRank returns a numeric rank for severity (higher = more severe). -func severityRank(severity string) int { - switch strings.ToLower(severity) { - case "critical": - return 4 - case "high": - return 3 - case "medium": - return 2 - case "low": - return 1 - default: - return 0 - } -} - // severityStyle returns the appropriate style for a severity level. func severityStyle(severity string) *cli.AnsiStyle { switch strings.ToLower(severity) { diff --git a/pkg/setup/cmd_wizard.go b/pkg/setup/cmd_wizard.go index d141faf..9964b29 100644 --- a/pkg/setup/cmd_wizard.go +++ b/pkg/setup/cmd_wizard.go @@ -41,7 +41,7 @@ func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string, // Build options var options []string - + // Sort by name sort.Slice(allRepos, func(i, j int) bool { return allRepos[i].Name < allRepos[j].Name @@ -90,4 +90,4 @@ func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string, func confirmClone(count int, target string) (bool, error) { confirmed := cli.Confirm(i18n.T("cmd.setup.wizard.confirm_clone", map[string]interface{}{"Count": count, "Target": target})) return confirmed, nil -} \ No newline at end of file +} diff --git a/pkg/setup/github_diff.go b/pkg/setup/github_diff.go index 8e64c3f..896210b 100644 --- a/pkg/setup/github_diff.go +++ b/pkg/setup/github_diff.go @@ -14,21 +14,31 @@ import ( // ChangeType indicates the type of change being made. type ChangeType string +// Change type constants for GitHub configuration diffs. const ( + // ChangeCreate indicates a new resource to be created. ChangeCreate ChangeType = "create" + // ChangeUpdate indicates an existing resource to be updated. ChangeUpdate ChangeType = "update" + // ChangeDelete indicates a resource to be deleted. ChangeDelete ChangeType = "delete" - ChangeSkip ChangeType = "skip" + // ChangeSkip indicates a resource that requires no changes. + ChangeSkip ChangeType = "skip" ) // ChangeCategory groups changes by type. type ChangeCategory string +// Change category constants for grouping GitHub configuration changes. const ( - CategoryLabel ChangeCategory = "label" - CategoryWebhook ChangeCategory = "webhook" + // CategoryLabel indicates label-related changes. + CategoryLabel ChangeCategory = "label" + // CategoryWebhook indicates webhook-related changes. + CategoryWebhook ChangeCategory = "webhook" + // CategoryProtection indicates branch protection changes. CategoryProtection ChangeCategory = "protection" - CategorySecurity ChangeCategory = "security" + // CategorySecurity indicates security settings changes. + CategorySecurity ChangeCategory = "security" ) // Change represents a single change to be made. diff --git a/pkg/test/cmd_output.go b/pkg/test/cmd_output.go index 8532c1c..7df7fa5 100644 --- a/pkg/test/cmd_output.go +++ b/pkg/test/cmd_output.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/host-uk/core/pkg/i18n" ) diff --git a/pkg/updater/build/main.go b/pkg/updater/build/main.go index 851ac13..563649c 100644 --- a/pkg/updater/build/main.go +++ b/pkg/updater/build/main.go @@ -3,13 +3,12 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" "os" ) func main() { // Read package.json - data, err := ioutil.ReadFile("package.json") + data, err := os.ReadFile("package.json") if err != nil { fmt.Println("Error reading package.json, skipping version file generation.") os.Exit(0) @@ -26,7 +25,7 @@ func main() { // Create the version file content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version) - err = ioutil.WriteFile("version.go", []byte(content), 0644) + err = os.WriteFile("version.go", []byte(content), 0644) if err != nil { fmt.Printf("Error writing version file: %v\n", err) os.Exit(1) diff --git a/pkg/updater/github.go b/pkg/updater/github.go index 676720e..63460cf 100644 --- a/pkg/updater/github.go +++ b/pkg/updater/github.go @@ -19,15 +19,15 @@ type Repo struct { // ReleaseAsset represents a single asset from a GitHub release. type ReleaseAsset struct { - Name string `json:"name"` // The name of the asset. + Name string `json:"name"` // The name of the asset. DownloadURL string `json:"browser_download_url"` // The URL to download the asset. } // Release represents a GitHub release. type Release struct { - TagName string `json:"tag_name"` // The name of the tag for the release. - PreRelease bool `json:"prerelease"` // Indicates if the release is a pre-release. - Assets []ReleaseAsset `json:"assets"` // A list of assets associated with the release. + TagName string `json:"tag_name"` // The name of the tag for the release. + PreRelease bool `json:"prerelease"` // Indicates if the release is a pre-release. + Assets []ReleaseAsset `json:"assets"` // A list of assets associated with the release. } // GithubClient defines the interface for interacting with the GitHub API. diff --git a/pkg/vm/cmd_vm.go b/pkg/vm/cmd_vm.go index 5be2c77..ef9f258 100644 --- a/pkg/vm/cmd_vm.go +++ b/pkg/vm/cmd_vm.go @@ -40,4 +40,4 @@ func AddVMCommands(root *cobra.Command) { addVMLogsCommand(vmCmd) addVMExecCommand(vmCmd) addVMTemplatesCommand(vmCmd) -} \ No newline at end of file +} diff --git a/pkg/workspace/cmd_workspace.go b/pkg/workspace/cmd_workspace.go index a25b116..204efe1 100644 --- a/pkg/workspace/cmd_workspace.go +++ b/pkg/workspace/cmd_workspace.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" ) +// AddWorkspaceCommands registers workspace management commands. func AddWorkspaceCommands(root *cobra.Command) { wsCmd := &cobra.Command{ Use: "workspace",