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
114 changes: 114 additions & 0 deletions internal/check/gowork.go
Original file line number Diff line number Diff line change
@@ -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()
}
137 changes: 137 additions & 0 deletions internal/check/gowork_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
3 changes: 3 additions & 0 deletions internal/check/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/detector/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions internal/detector/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading