feat: git command, build improvements, and go fmt git-aware (#74)

* feat(go): make go fmt git-aware by default

- By default, only check changed Go files (modified, staged, untracked)
- Add --all flag to check all files (previous behaviour)
- Reduces noise when running fmt on large codebases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(build): minimal output by default, add missing i18n

- Default output now shows single line: "Success Built N artifacts (dir)"
- Add --verbose/-v flag to show full detailed output
- Add all missing i18n translations for build commands
- Errors still show failure reason in minimal mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add root-level `core git` command

- Create pkg/gitcmd with git workflow commands as root menu
- Export command builders from pkg/dev (AddCommitCommand, etc.)
- Commands available under both `core git` and `core dev` for compatibility
- Git commands: health, commit, push, pull, work, sync, apply
- GitHub orchestration stays in dev: issues, reviews, ci, impact

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(qa): add docblock coverage checking

Implement docblock/docstring coverage analysis for Go code:
- New `core qa docblock` command to check coverage
- Shows compact file:line list when under threshold
- Integrate with `core go qa` as a default check
- Add --docblock-threshold flag (default 80%)

The checker uses Go AST parsing to find exported symbols
(functions, types, consts, vars) without documentation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit review feedback

- Fix doc comment: "status" → "health" in gitcmd package
- Implement --check flag for `core go fmt` (exits non-zero if files need formatting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add docstrings for 100% coverage

Add documentation comments to all exported symbols:
- pkg/build: ProjectType constants
- pkg/cli: LogLevel, RenderStyle, TableStyle
- pkg/framework: ServiceFor, MustServiceFor, Core.Core
- pkg/git: GitError.Error, GitError.Unwrap
- pkg/i18n: Handler Match/Handle methods
- pkg/log: Level constants
- pkg/mcp: Tool input/output types
- pkg/php: Service constants, QA types, service methods
- pkg/process: ServiceError.Error
- pkg/repos: RepoType constants
- pkg/setup: ChangeType, ChangeCategory constants
- pkg/workspace: AddWorkspaceCommands

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: standardize line endings to LF

Add .gitattributes to enforce LF line endings for all text files.
Normalize all existing files to use Unix-style line endings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit review feedback

- cmd_format.go: validate --check/--fix mutual exclusivity, capture stderr
- cmd_docblock.go: return error instead of os.Exit(1) for proper error handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit review feedback (round 2)

- linuxkit.go: propagate state update errors, handle cmd.Wait() errors in waitForExit
- mcp.go: guard against empty old_string in editDiff to prevent runaway edits
- cmd_docblock.go: log parse errors instead of silently skipping

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-01 10:48:44 +00:00 committed by GitHub
parent fa8e5334a5
commit 0072650fd9
105 changed files with 1608 additions and 674 deletions

23
.gitattributes vendored Normal file
View file

@ -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

View file

@ -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{

View file

@ -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"

View file

@ -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"

View file

@ -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
)

View file

@ -286,4 +286,4 @@ func formatTaskStatus(s agentic.TaskStatus) string {
default:
return dimStyle.Render(string(s))
}
}
}

View file

@ -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"
)

View file

@ -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"))

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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())
}
}

View file

@ -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")
})
}
}

View file

@ -54,4 +54,4 @@ func latestTag(dir string) (string, error) {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
}

View file

@ -40,4 +40,4 @@ func runCIReleaseInit() error {
cli.Print(" %s\n", i18n.T("cmd.ci.init.run_ci"))
return nil
}
}

View file

@ -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)
}
}

View file

@ -88,4 +88,4 @@ func (c *CheckBuilder) String() string {
// Print outputs the check result.
func (c *CheckBuilder) Print() {
fmt.Println(c.String())
}
}

View file

@ -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
}

View file

@ -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()

View file

@ -86,4 +86,4 @@ func replaceGlyph(input *bytes.Buffer) string {
return Glyph(code.String())
}
}
}
}

View file

@ -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.

View file

@ -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))
}
}
}

View file

@ -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")

View file

@ -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
)

View file

@ -163,4 +163,4 @@ func Result(passed bool, message string) {
} else {
Error(message)
}
}
}

View file

@ -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()
}

View file

@ -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)

View file

@ -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() {

View file

@ -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 {

View file

@ -45,4 +45,4 @@ func InfoStr(msg string) string {
// DimStr returns dim-styled string.
func DimStr(msg string) string {
return DimStyle.Render(msg)
}
}

View file

@ -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())
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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"),

View file

@ -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(),

View file

@ -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":

View file

@ -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
}
}

View file

@ -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)

View file

@ -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 <file-or-dir>",
Short: i18n.T("cmd.dev.file_sync.short"),

View file

@ -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"),

View file

@ -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)
}

View file

@ -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"),

View file

@ -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"),

View file

@ -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()
}

View file

@ -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)

View file

@ -251,4 +251,4 @@ func TestLoadConfig_Bad_UnreadableFile(t *testing.T) {
// Restore permissions so cleanup works
os.Chmod(configPath, 0644)
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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{

View file

@ -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()

View file

@ -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
}
}

View file

@ -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.

View file

@ -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
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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

44
pkg/gitcmd/cmd_git.go Normal file
View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -797,4 +797,3 @@ func TestProgressSubjectMatchesExpected(t *testing.T) {
})
}
}

View file

@ -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()

View file

@ -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",

View file

@ -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
)

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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() {

View file

@ -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{

View file

@ -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")
})

View file

@ -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)

View file

@ -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",

View file

@ -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.

View file

@ -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,

View file

@ -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")

View file

@ -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"
)

View file

@ -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

View file

@ -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
}

View file

@ -127,6 +127,7 @@ type ServiceError struct {
msg string
}
// Error returns the service error message.
func (e *ServiceError) Error() string {
return e.msg
}

View file

@ -255,4 +255,3 @@ func TestService_Clear(t *testing.T) {
assert.Len(t, svc.List(), 0)
})
}

353
pkg/qa/cmd_docblock.go Normal file
View file

@ -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"
}
}

View file

@ -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
}

View file

@ -41,4 +41,5 @@ func AddQACommands(root *cli.Command) {
addReviewCommand(qaCmd)
addHealthCommand(qaCmd)
addIssuesCommand(qaCmd)
addDocblockCommand(qaCmd)
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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",
},

View file

@ -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
}
}

View file

@ -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")
})
}
}

View file

@ -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")

View file

@ -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 {

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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.

Some files were not shown because too many files have changed in this diff Show more