Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ func init() {

//nolint:cyclop,funlen // runAnalyze orchestrates 6+ analysis categories
func runAnalyze(_ *cobra.Command, _ []string) error {
jsonMode := analyzeOpts.output == "json"

// In JSON mode, only valid JSON may go to stdout. Route all human/progress
// output (headers, steps, summary) to stderr so the JSON on stdout stays
// machine-parseable. The writer is restored before returning.
if jsonMode && ui != nil {
prevWriter := ui.writer
ui.writer = os.Stderr
defer func() { ui.writer = prevWriter }()
}

ui.Header("Goca Analyze — Deep Project Self-Analysis")
ui.Blank()

Expand Down Expand Up @@ -116,7 +127,7 @@ func runAnalyze(_ *cobra.Command, _ []string) error {

ui.Blank()

if analyzeOpts.output == "json" {
if jsonMode {
printAnalyzeJSON(results)
} else {
printAnalyzeReport(results)
Expand All @@ -134,7 +145,11 @@ func runAnalyze(_ *cobra.Command, _ []string) error {
return fmt.Errorf("analyze: %d rule(s) failed", failed) //nolint:err113 // dynamic count is intentional
}
if analyzeOpts.failOnWarn && warned > 0 {
return fmt.Errorf("analyze: %d warning(s) (--fail-on-warn)", warned) //nolint:err113 // dynamic count is intentional
// Warnings-only with --fail-on-warn must exit with code 2, distinct from
// the failure exit code 1. Returning an error here would map to exit 1
// via Execute(), so exit directly with the documented code.
ui.Warning(fmt.Sprintf("analyze: %d warning(s) (--fail-on-warn)", warned))
os.Exit(2)
}
return nil
}
Expand Down
47 changes: 34 additions & 13 deletions cmd/analyze_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@ func checkExportedFunctionsHaveDocs() analyzeResult {
}

func checkMainGoExists() analyzeResult {
candidates := []string{"main.go", "cmd/main.go"}
// Fixed entry points checked first, then any cmd/<name>/main.go (goca init
// generates cmd/server/main.go, so a fixed "cmd/main.go" check is not enough).
candidates := []string{"main.go", "cmd/main.go", "cmd/server/main.go"}
for _, c := range candidates {
if fileExists(c) {
return analyzeResult{
Expand All @@ -465,11 +467,28 @@ func checkMainGoExists() analyzeResult {
}
}
}
// Accept any cmd/<name>/main.go layout.
if entries, err := os.ReadDir("cmd"); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
c := filepath.Join("cmd", e.Name(), "main.go")
if fileExists(c) {
return analyzeResult{
category: "Quality",
rule: "main-go",
status: "✓",
message: fmt.Sprintf("Entry point found at %s", c),
}
}
}
}
return analyzeResult{
category: "Quality",
rule: "main-go",
status: "⚠",
message: "No main.go found at root or cmd/",
message: "No main.go found at root, cmd/, or cmd/<name>/",
suggestion: "Run: goca init <project-name> to generate project entry point",
}
}
Expand All @@ -492,6 +511,8 @@ func checkNoHardcodedSecrets() analyzeResult {
patterns := []string{
"password = \"", "secret = \"", "token = \"",
"apikey = \"", "api_key = \"", "passwd = \"",
"password := \"", "secret := \"", "token := \"",
"apikey := \"", "api_key := \"", "passwd := \"",
`password:"`, `secret:"`, `token:"`,
}
files, _ := analyzeGoFiles("internal", false)
Expand Down Expand Up @@ -595,24 +616,24 @@ func checkNoInsecureHTTPClient() analyzeResult {
}

func checkEnvVarsForSensitiveConfig() analyzeResult {
// Verify sensitive config is read from environment, not config files or constants
files, _ := analyzeGoFiles("internal", true)
// Verify sensitive config is read from environment, not config files or constants.
// goca init generates pkg/config/config.go (which uses os.Getenv), so pkg/ must
// be scanned in addition to internal/ to avoid false warnings.
usesEnv := false
for _, f := range files {
if strings.Contains(analyzeReadFile(f), "os.Getenv") {
usesEnv = true
break
for _, dir := range []string{"internal", "pkg"} {
if !dirExists(dir) {
continue
}
}
// Check cache package separately
if !usesEnv && dirExists("internal/cache") {
cacheFiles, _ := analyzeGoFiles("internal/cache", true)
for _, f := range cacheFiles {
files, _ := analyzeGoFiles(dir, true)
for _, f := range files {
if strings.Contains(analyzeReadFile(f), "os.Getenv") {
usesEnv = true
break
}
}
if usesEnv {
break
}
}
if usesEnv {
return analyzeResult{
Expand Down
45 changes: 39 additions & 6 deletions cmd/cache_decorator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"
"path/filepath"
"strings"
)
Expand All @@ -18,6 +19,11 @@ func generateCacheDecorator(entity string, fields []Field, sm ...*SafetyManager)
moduleName := getModuleName()
importPath := getImportPath(moduleName)

// Detect whether the interface includes transaction methods so the decorator
// can also delegate the WithTx methods and remain substitutable for the
// <Entity>Repository interface.
transactions := interfaceHasTransactions(filepath.Join(repoDir, "interfaces.go"), entity)

var b strings.Builder

b.WriteString("package repository\n\n")
Expand All @@ -28,6 +34,9 @@ func generateCacheDecorator(entity string, fields []Field, sm ...*SafetyManager)
b.WriteString("\t\"time\"\n\n")
b.WriteString(fmt.Sprintf("\t\"%s/internal/domain\"\n\n", importPath))
b.WriteString("\t\"github.com/redis/go-redis/v9\"\n")
if transactions {
b.WriteString("\t\"gorm.io/gorm\"\n")
}
b.WriteString(")\n\n")

// Struct
Expand Down Expand Up @@ -89,17 +98,15 @@ func generateCacheDecorator(entity string, fields []Field, sm ...*SafetyManager)
b.WriteString("\treturn entity, nil\n")
b.WriteString("}\n\n")

// Dynamic search methods from fields — delegate only (no caching for search)
// Dynamic search methods from fields — delegate only (no caching for search).
// Only emit finders that actually exist on the interface (derived from the
// parsed fields); when no fields are known, emit none so we never reference
// an undefined inner method like FindByEmail on an entity without that field.
if len(fields) > 0 {
searchMethods := generateSearchMethods(fields, entity)
for _, m := range searchMethods {
generateCacheSearchMethodDelegate(&b, entity, m)
}
} else {
// Default FindByEmail when no fields are specified
b.WriteString(fmt.Sprintf("func (r *Cached%sRepository) FindByEmail(email string) (*domain.%s, error) {\n", entity, entity))
b.WriteString("\treturn r.inner.FindByEmail(email)\n")
b.WriteString("}\n\n")
}

// Update — delegate + invalidate
Expand Down Expand Up @@ -140,11 +147,37 @@ func generateCacheDecorator(entity string, fields []Field, sm ...*SafetyManager)
b.WriteString("\treturn entities, nil\n")
b.WriteString("}\n")

// Transaction methods — delegate to inner so the decorator still satisfies a
// transactional <Entity>Repository interface.
if transactions {
b.WriteString("\n")
fmt.Fprintf(&b, "func (r *Cached%sRepository) SaveWithTx(tx *gorm.DB, %s *domain.%s) error {\n", entity, entityLower, entity)
fmt.Fprintf(&b, "\treturn r.inner.SaveWithTx(tx, %s)\n", entityLower)
b.WriteString("}\n\n")
fmt.Fprintf(&b, "func (r *Cached%sRepository) UpdateWithTx(tx *gorm.DB, %s *domain.%s) error {\n", entity, entityLower, entity)
fmt.Fprintf(&b, "\treturn r.inner.UpdateWithTx(tx, %s)\n", entityLower)
b.WriteString("}\n\n")
fmt.Fprintf(&b, "func (r *Cached%sRepository) DeleteWithTx(tx *gorm.DB, id int) error {\n", entity)
b.WriteString("\treturn r.inner.DeleteWithTx(tx, id)\n")
b.WriteString("}\n")
}

if err := writeGoFile(filename, b.String(), sm...); err != nil {
ui.Error(fmt.Sprintf("Error writing cache decorator: %v", err))
}
}

// interfaceHasTransactions reports whether the generated repository interface for
// the entity declares transactional methods (SaveWithTx). It reads the already
// generated interfaces.go; if it cannot be read, it returns false.
func interfaceHasTransactions(interfacesPath, entity string) bool {
data, err := os.ReadFile(interfacesPath)
if err != nil {
return false
}
return strings.Contains(string(data), fmt.Sprintf("SaveWithTx(tx *gorm.DB, %s *domain.%s)", strings.ToLower(entity), entity))
}

// generateCacheSearchMethodDelegate generates a delegate-only method for a search method.
func generateCacheSearchMethodDelegate(b *strings.Builder, entity string, m SearchMethod) {
paramName := strings.ToLower(m.FieldName)
Expand Down
5 changes: 3 additions & 2 deletions cmd/cache_decorator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ func TestGenerateCacheDecorator_NoFields(t *testing.T) {
require.NoError(t, err)
src := string(content)

// Default FindByEmail delegate when no fields
assert.Contains(t, src, "func (r *CachedItemRepository) FindByEmail")
// With no fields the interface declares no finders, so the decorator must NOT
// emit a FindByEmail delegate (the inner repo has no such method).
assert.NotContains(t, src, "FindByEmail")
}

func TestGenerateCacheDecorator_DryRun(t *testing.T) {
Expand Down
23 changes: 21 additions & 2 deletions cmd/cache_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,23 @@ func TestGenerateCachePackage_Imports(t *testing.T) {
assert.Contains(t, src, `"github.com/redis/go-redis/v9"`)
}

// writeFakeCacheDecorators creates cached_<entity>_repository.go stubs under the
// current working directory so cache-aware DI generation recognizes the decorators.
func writeFakeCacheDecorators(t *testing.T, features ...string) {
t.Helper()
repoDir := filepath.Join(DirInternal, DirRepository)
require.NoError(t, os.MkdirAll(repoDir, 0o755))
for _, f := range features {
fn := filepath.Join(repoDir, "cached_"+strings.ToLower(f)+"_repository.go")
require.NoError(t, os.WriteFile(fn, []byte("package repository"), 0o644))
}
}

func TestGenerateSetupRepositories_WithCache(t *testing.T) {
t.Parallel()
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(t.TempDir())
writeFakeCacheDecorators(t, "Product", "User")

var b strings.Builder
features := []string{"Product", "User"}
Expand All @@ -110,7 +125,10 @@ func TestGenerateSetupRepositories_WithCache(t *testing.T) {
}

func TestGenerateSetupRepositories_WithCacheMySQL(t *testing.T) {
t.Parallel()
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(t.TempDir())
writeFakeCacheDecorators(t, "Order")

var b strings.Builder
generateSetupRepositories(&b, []string{"Order"}, "mysql", true)
Expand All @@ -130,6 +148,7 @@ func TestGenerateManualDI_WithCache(t *testing.T) {
os.Chdir(dir)

require.NoError(t, os.WriteFile("go.mod", []byte("module testproject\n\ngo 1.21\n"), 0o644))
writeFakeCacheDecorators(t, "Product")

sm := NewSafetyManager(false, true, false)
generateManualDI(filepath.Join(dir, "di"), []string{"Product"}, "postgres", true, sm)
Expand Down
Loading
Loading