diff --git a/internal/check/gowork.go b/internal/check/gowork.go new file mode 100644 index 0000000..ef7f060 --- /dev/null +++ b/internal/check/gowork.go @@ -0,0 +1,114 @@ +package check + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// GoWorkCheck reads go.work and verifies that every module listed under a +// "use" directive exists on disk as a directory containing a go.mod file. +type GoWorkCheck struct { + Dir string +} + +func (c *GoWorkCheck) Name() string { + return "Go workspace modules present" +} + +func (c *GoWorkCheck) Run(_ context.Context) Result { + workFile := filepath.Join(c.Dir, "go.work") + paths, err := parseGoWorkUse(workFile) + if err != nil { + return Result{ + Name: c.Name(), + Status: StatusFail, + Message: fmt.Sprintf("could not read go.work: %v", err), + } + } + + if len(paths) == 0 { + return Result{ + Name: c.Name(), + Status: StatusPass, + Message: "go.work has no use directives", + } + } + + var missing []string + for _, p := range paths { + modPath := filepath.Join(c.Dir, p, "go.mod") + if _, err := os.Stat(modPath); os.IsNotExist(err) { + missing = append(missing, p) + } + } + + if len(missing) > 0 { + return Result{ + Name: c.Name(), + Status: StatusFail, + Message: fmt.Sprintf("go.work references modules with missing go.mod: %s", strings.Join(missing, ", ")), + Fix: "ensure each path listed under 'use' in go.work exists and contains a go.mod file", + } + } + + return Result{ + Name: c.Name(), + Status: StatusPass, + Message: fmt.Sprintf("all %d go.work module(s) present", len(paths)), + } +} + +// parseGoWorkUse returns the list of paths from "use" directives in go.work. +// It handles both single-line form ("use ./foo") and block form ("use (\n./foo\n)"). +func parseGoWorkUse(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var paths []string + inBlock := false + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Strip inline comments. + if idx := strings.Index(line, "//"); idx >= 0 { + line = strings.TrimSpace(line[:idx]) + } + + if line == "" { + continue + } + + if inBlock { + if line == ")" { + inBlock = false + continue + } + paths = append(paths, line) + continue + } + + if strings.HasPrefix(line, "use") { + rest := strings.TrimSpace(strings.TrimPrefix(line, "use")) + if rest == "(" { + inBlock = true + continue + } + // Block opener on same line: "use (" already handled above; + // single path form: "use ./foo" + if rest != "" { + paths = append(paths, rest) + } + } + } + + return paths, scanner.Err() +} \ No newline at end of file diff --git a/internal/check/gowork_test.go b/internal/check/gowork_test.go new file mode 100644 index 0000000..0f9fd18 --- /dev/null +++ b/internal/check/gowork_test.go @@ -0,0 +1,137 @@ +package check + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeGoWork(t *testing.T, dir, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "go.work"), []byte(content), 0o644); err != nil { + t.Fatalf("write go.work: %v", err) + } +} + +func mkGoMod(t *testing.T, dir, rel string) { + t.Helper() + p := filepath.Join(dir, rel) + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", p, err) + } + if err := os.WriteFile(filepath.Join(p, "go.mod"), []byte("module example.com/mod\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } +} + +func TestGoWorkCheck_Pass_BlockForm(t *testing.T) { + dir := t.TempDir() + writeGoWork(t, dir, "go 1.22\n\nuse (\n\t./svc/api\n\t./svc/worker\n)\n") + mkGoMod(t, dir, "svc/api") + mkGoMod(t, dir, "svc/worker") + + c := &GoWorkCheck{Dir: dir} + r := c.Run(context.Background()) + if r.Status != StatusPass { + t.Errorf("expected pass, got %v: %s", r.Status, r.Message) + } +} + +func TestGoWorkCheck_Pass_SingleLineForm(t *testing.T) { + dir := t.TempDir() + writeGoWork(t, dir, "go 1.22\n\nuse ./svc/api\n") + mkGoMod(t, dir, "svc/api") + + c := &GoWorkCheck{Dir: dir} + r := c.Run(context.Background()) + if r.Status != StatusPass { + t.Errorf("expected pass, got %v: %s", r.Status, r.Message) + } +} + +func TestGoWorkCheck_Fail_MissingModule(t *testing.T) { + dir := t.TempDir() + writeGoWork(t, dir, "go 1.22\n\nuse (\n\t./svc/api\n\t./svc/missing\n)\n") + mkGoMod(t, dir, "svc/api") + // svc/missing intentionally absent + + c := &GoWorkCheck{Dir: dir} + r := c.Run(context.Background()) + if r.Status != StatusFail { + t.Fatalf("expected fail, got %v: %s", r.Status, r.Message) + } + if !strings.Contains(r.Message, "svc/missing") { + t.Errorf("expected missing path in message, got: %s", r.Message) + } +} + +func TestGoWorkCheck_Fail_DirExistsButNoGoMod(t *testing.T) { + dir := t.TempDir() + writeGoWork(t, dir, "go 1.22\n\nuse ./svc/api\n") + // create the directory but no go.mod inside + if err := os.MkdirAll(filepath.Join(dir, "svc", "api"), 0o755); err != nil { + t.Fatal(err) + } + + c := &GoWorkCheck{Dir: dir} + r := c.Run(context.Background()) + if r.Status != StatusFail { + t.Errorf("expected fail when directory exists but go.mod missing, got %v", r.Status) + } +} + +func TestGoWorkCheck_Pass_NoUseDirectives(t *testing.T) { + dir := t.TempDir() + writeGoWork(t, dir, "go 1.22\n") + + c := &GoWorkCheck{Dir: dir} + r := c.Run(context.Background()) + if r.Status != StatusPass { + t.Errorf("expected pass for go.work with no use directives, got %v: %s", r.Status, r.Message) + } +} + +func TestGoWorkCheck_Fail_MissingGoWorkFile(t *testing.T) { + dir := t.TempDir() + // no go.work written + + c := &GoWorkCheck{Dir: dir} + r := c.Run(context.Background()) + if r.Status != StatusFail { + t.Errorf("expected fail when go.work is missing, got %v", r.Status) + } +} + +func TestGoWorkCheck_IgnoresComments(t *testing.T) { + dir := t.TempDir() + writeGoWork(t, dir, "go 1.22\n\nuse (\n\t./svc/api // main service\n\t// ./svc/disabled\n)\n") + mkGoMod(t, dir, "svc/api") + // svc/disabled should be ignored + + c := &GoWorkCheck{Dir: dir} + r := c.Run(context.Background()) + if r.Status != StatusPass { + t.Errorf("expected pass, got %v: %s", r.Status, r.Message) + } +} + +func TestParseGoWorkUse_BlockAndSingle(t *testing.T) { + dir := t.TempDir() + writeGoWork(t, dir, "go 1.22\n\nuse (\n\t./a\n\t./b\n)\n\nuse ./c\n") + + paths, err := parseGoWorkUse(filepath.Join(dir, "go.work")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"./a", "./b", "./c"} + if len(paths) != len(want) { + t.Fatalf("expected %v, got %v", want, paths) + } + for i, p := range paths { + if p != want[i] { + t.Errorf("paths[%d]: expected %q, got %q", i, want[i], p) + } + } +} \ No newline at end of file diff --git a/internal/check/registry.go b/internal/check/registry.go index 6ba63c6..0b9aab8 100644 --- a/internal/check/registry.go +++ b/internal/check/registry.go @@ -17,6 +17,9 @@ func Build(stack detector.DetectedStack) []Check { } cs = append(cs, &GoVersionCheck{Dir: "."}) cs = append(cs, &DepsCheck{Dir: ".", Stack: "go"}) + if stack.GoWork { + cs = append(cs, &GoWorkCheck{Dir: "."}) + } } if stack.Node { pm := stack.PackageManager diff --git a/internal/detector/detector.go b/internal/detector/detector.go index a7ccde6..1ee7f21 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -8,6 +8,7 @@ import ( type DetectedStack struct { Go bool + GoWork bool Node bool // PackageManager is the Node package manager inferred from the lockfile. // Possible values: "npm", "pnpm", "yarn". Empty string when Node is false. @@ -29,6 +30,7 @@ func Detect(dir string) DetectedStack { stack := DetectedStack{} stack.Go = fileExists(filepath.Join(dir, "go.mod")) + stack.GoWork = fileExists(filepath.Join(dir, "go.work")) stack.Node = fileExists(filepath.Join(dir, "package.json")) if stack.Node { switch { diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index 7f5ff2e..d8d5a3b 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -121,4 +121,23 @@ func TestDetect_DockerCompose_false_when_absent(t *testing.T) { if stack.DockerCompose { t.Error("expected DockerCompose=false when no compose file present") } +} + +func TestDetect_GoWork_true_when_present(t *testing.T) { + dir := t.TempDir() + touch(t, filepath.Join(dir, "go.mod")) + touch(t, filepath.Join(dir, "go.work")) + stack := Detect(dir) + if !stack.GoWork { + t.Error("expected GoWork=true when go.work exists") + } +} + +func TestDetect_GoWork_false_when_absent(t *testing.T) { + dir := t.TempDir() + touch(t, filepath.Join(dir, "go.mod")) + stack := Detect(dir) + if stack.GoWork { + t.Error("expected GoWork=false when go.work is absent") + } } \ No newline at end of file