diff --git a/cmd/sin-code/cover_cmd.doc.md b/cmd/sin-code/cover_cmd.doc.md new file mode 100644 index 00000000..f7c6ce9c --- /dev/null +++ b/cmd/sin-code/cover_cmd.doc.md @@ -0,0 +1,59 @@ +# `sin-code cover` + +Coverage-Drohne — a state-of-the-art coverage automation tool for SIN-Code. + +## Subcommands + +- `scan` — print package coverage table (text or JSON). +- `check` — fail CI if any package is below `--min` coverage. +- `gaps` — list uncovered functions/blocks per package. +- `generate` — write an AI test-generation request JSON. +- `hook` — print or install a git pre-commit coverage gate. + +## SOTA design + +Coverage-Drohne combines the best practices from industrial LLM test-generation +(Meta TestGen-LLM, Qodo Cover) with Go-native tooling: + +1. **Coverage-driven**: every run produces a machine-readable coverage profile. +2. **Gap-aware**: `gaps` pinpoints the exact functions and blocks that are + not covered. +3. **AI test-gen requests**: `generate` emits a JSON prompt that can be fed to + the agent loop or to an external LLM to produce the missing tests. +4. **CI gate**: `check --min 100` is deterministic and can be used as a + merge-blocking gate. + +Future versions will add mutation-testing integration (`go-mutesting`), +coverage-guided fuzzing (`go test -fuzz`), and a file-watcher that auto-triggers +test generation after `sin-code` writes or edits a Go file. + +## Usage + +```bash +sin-code cover scan +sin-code cover scan --json +sin-code cover check --min 100 +sin-code cover gaps --package coverdrohne +sin-code cover generate --package coverdrohne --out req.json +sin-code cover hook +sin-code cover hook --install +``` + +## Exit codes + +- `0` — scan/check passed, no package below threshold. +- `1` — check found a package below threshold (text mode) or a command error. + +## Machine-readable output + +`cover check --json` always exits `0` and prints a JSON envelope: + +```json +{ + "passed": false, + "min": 100, + "failed": [ + {"import_path": "...", "coverage": 42.0, "statements": 100, "covered": 42} + ] +} +``` diff --git a/cmd/sin-code/cover_cmd.go b/cmd/sin-code/cover_cmd.go new file mode 100644 index 00000000..35364577 --- /dev/null +++ b/cmd/sin-code/cover_cmd.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code cover` — Coverage-Drohne entry point. +// Subcommands: +// cover scan # package coverage table +// cover check # CI gate with --min +// cover gaps # uncovered functions/blocks +// cover generate # AI test-generation request JSON +// +// Driver logic lives in cmd/sin-code/internal/coverdrohne. +// Docs: cover_cmd.doc.md +package main + +import ( + "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/coverdrohne" +) + +// NewCoverCmd returns the `sin-code cover` subcommand. +func NewCoverCmd() *cobra.Command { + return coverdrohne.NewCommand() +} diff --git a/cmd/sin-code/internal/coverdrohne/coverdrohne.go b/cmd/sin-code/internal/coverdrohne/coverdrohne.go new file mode 100644 index 00000000..79f91642 --- /dev/null +++ b/cmd/sin-code/internal/coverdrohne/coverdrohne.go @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code cover` command implementation — scan coverage, check gates, +// and prepare test-generation requests. +package coverdrohne + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +// mkdirTempCmd is the temp-dir hook used by command helpers. +var mkdirTempCmd = os.MkdirTemp + +// writeFileHook is swappable for tests that exercise the --out write path. +var writeFileHook = os.WriteFile + +// runGoTestHook is swappable for tests that exercise the go-test error paths. +var runGoTestHook = defaultRunGoTest + +// mkdirAllHook is swappable for tests that exercise the hook install error path. +var mkdirAllHook = os.MkdirAll + +// writeFileModeHook is swappable for tests that exercise the hook install write error path. +var writeFileModeHook = func(name string, data []byte, perm os.FileMode) error { return os.WriteFile(name, data, perm) } + +// scanWithProfileHook is swappable for tests that exercise the scanWithProfile error path. +var scanWithProfileHook = scanWithProfile + +// jsonMarshalIndentHook is swappable for tests that exercise the JSON marshal error path. +var jsonMarshalIndentHook = json.MarshalIndent + +// NewCommand returns the `sin-code cover` cobra command. +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "cover", + Short: "Coverage scanner and test-generation coordinator", + Long: `sin-code cover scans the Go coverage of all SIN-Code packages +and reports which ones are below the configured threshold. + + sin-code cover scan # text table of package coverage + sin-code cover scan --json # JSON output + sin-code cover check --min 100 # exit 1 if any package below 100% + sin-code cover gaps --package # uncovered functions/blocks for a package + sin-code cover generate --package --out req.json # AI test-gen request + sin-code cover hook # print a git pre-commit coverage gate + sin-code cover hook --install # install .git/hooks/pre-commit`, + } + cmd.AddCommand( + newScanCmd(), + newCheckCmd(), + newGapsCmd(), + newGenerateCmd(), + newHookCmd(), + ) + return cmd +} + +func newScanCmd() *cobra.Command { + var jsonOut bool + var packages string + var root string + cmd := &cobra.Command{ + Use: "scan", + Short: "Scan package coverage and print a report", + RunE: func(cmd *cobra.Command, args []string) error { + scanner := NewScanner() + scanner.Root = root + scanner.Packages = packages + results, err := scanner.Scan() + if err != nil { + return err + } + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(results) + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "%-70s %8s\n", "Package", "Coverage") + fmt.Fprintf(w, "%s\n", strings.Repeat("-", 80)) + for _, r := range results { + fmt.Fprintf(w, "%-70s %7.1f%%\n", r.ImportPath, r.Coverage) + } + return nil + }, + } + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON report") + cmd.Flags().StringVar(&packages, "packages", "./cmd/sin-code/...", "package pattern") + cmd.Flags().StringVar(&root, "root", ".", "module root directory") + return cmd +} + +func newCheckCmd() *cobra.Command { + var min float64 + var packages string + var jsonOut bool + var root string + cmd := &cobra.Command{ + Use: "check", + Short: "Fail if any package is below the coverage threshold", + RunE: func(cmd *cobra.Command, args []string) error { + scanner := NewScanner() + scanner.Root = root + scanner.Packages = packages + results, err := scanner.Scan() + if err != nil { + return err + } + var failed []PackageCoverage + for _, r := range results { + if r.Coverage < min { + failed = append(failed, r) + } + } + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(map[string]any{ + "passed": len(failed) == 0, + "min": min, + "failed": failed, + }) + } + if len(failed) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "✓ all packages meet %.1f%% coverage\n", min) + return nil + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "✗ %d package(s) below %.1f%% coverage:\n", len(failed), min) + for _, r := range failed { + fmt.Fprintf(w, " %s %.1f%%\n", r.ImportPath, r.Coverage) + } + return fmt.Errorf("coverage gate failed") + }, + } + cmd.Flags().Float64Var(&min, "min", 100, "minimum coverage percentage") + cmd.Flags().StringVar(&packages, "packages", "./cmd/sin-code/...", "package pattern") + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON report") + cmd.Flags().StringVar(&root, "root", ".", "module root directory") + return cmd +} + +func newGapsCmd() *cobra.Command { + var pkg string + var jsonOut bool + var packages string + var root string + cmd := &cobra.Command{ + Use: "gaps", + Short: "Show uncovered blocks for a package", + RunE: func(cmd *cobra.Command, args []string) error { + coverprofile, err := runCoverageProfile(root, packages) + if err != nil { + return err + } + defer os.RemoveAll(filepath.Dir(coverprofile)) + gaps, err := Gaps(coverprofile, root) + if err != nil { + return err + } + if pkg != "" { + gaps = filterGapsByPackage(gaps, pkg) + } + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(gaps) + } + w := cmd.OutOrStdout() + for _, g := range gaps { + fmt.Fprintf(w, "%s\n", g.File) + for _, b := range g.Blocks { + fmt.Fprintf(w, " %s:%d-%d (%d stmts)\n", b.FuncName, b.StartLine, b.EndLine, b.NumStmts) + } + } + return nil + }, + } + cmd.Flags().StringVar(&pkg, "package", "", "filter by package import path substring") + cmd.Flags().StringVar(&packages, "packages", "./cmd/sin-code/...", "package pattern") + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON report") + cmd.Flags().StringVar(&root, "root", ".", "module root directory") + return cmd +} + +func newGenerateCmd() *cobra.Command { + var pkg string + var out string + var packages string + var root string + cmd := &cobra.Command{ + Use: "generate", + Short: "Write a test-generation request JSON for a package", + RunE: func(cmd *cobra.Command, args []string) error { + if pkg == "" { + return fmt.Errorf("--package is required") + } + coverprofile, err := runCoverageProfile(root, packages) + if err != nil { + return err + } + defer os.RemoveAll(filepath.Dir(coverprofile)) + + results, err := scanWithProfileHook(root, packages, coverprofile) + if err != nil { + return err + } + var target *PackageCoverage + for i := range results { + if strings.Contains(results[i].ImportPath, pkg) { + target = &results[i] + break + } + } + if target == nil { + return fmt.Errorf("package %q not found in coverage scan", pkg) + } + + gaps, err := Gaps(coverprofile, root) + if err != nil { + return err + } + gaps = filterGapsByPackage(gaps, pkg) + + req := map[string]any{ + "package": target.ImportPath, + "coverage": target.Coverage, + "gaps": gaps, + "prompt": fmt.Sprintf("Add Go tests to bring %s from %.1f%% to 100%% coverage. "+ + "Target the uncovered functions/blocks listed in gaps.", target.ImportPath, target.Coverage), + } + data, err := jsonMarshalIndentHook(req, "", " ") + if err != nil { + return err + } + if out == "" { + _, err = cmd.OutOrStdout().Write(data) + _, _ = cmd.OutOrStdout().Write([]byte("\n")) + return err + } + return writeFileHook(out, data, 0o644) + }, + } + cmd.Flags().StringVar(&pkg, "package", "", "package import path substring") + cmd.Flags().StringVar(&out, "out", "", "output file (default: stdout)") + cmd.Flags().StringVar(&packages, "packages", "./cmd/sin-code/...", "package pattern") + cmd.Flags().StringVar(&root, "root", ".", "module root directory") + return cmd +} + +func newHookCmd() *cobra.Command { + var min float64 + var install bool + cmd := &cobra.Command{ + Use: "hook", + Short: "Print or install a git pre-commit hook that runs the coverage gate", + RunE: func(cmd *cobra.Command, args []string) error { + script := preCommitHookScript(min) + if !install { + _, err := cmd.OutOrStdout().Write([]byte(script)) + return err + } + dir := ".git/hooks" + if err := mkdirAllHook(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, "pre-commit") + if err := writeFileModeHook(path, []byte(script), 0o755); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "installed %s\n", path) + return nil + }, + } + cmd.Flags().Float64Var(&min, "min", 100, "minimum coverage percentage") + cmd.Flags().BoolVar(&install, "install", false, "write the hook to .git/hooks/pre-commit") + return cmd +} + +func preCommitHookScript(min float64) string { + return fmt.Sprintf(`#!/bin/sh +# Generated by sin-code cover hook. Runs the coverage gate before every commit. +set -e +sin-code cover check --min %.1f +`, min) +} + +func runCoverageProfile(root, packages string) (string, error) { + if packages == "" { + packages = "./cmd/sin-code/..." + } + tmpDir, err := mkdirTempCmd("", "sin-cover-drohne-*") + if err != nil { + return "", err + } + coverprofile := filepath.Join(tmpDir, "coverage.out") + out, err := runGoTestHook(root, packages, coverprofile) + if err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("go test failed: %w\n%s", err, string(out)) + } + return coverprofile, nil +} + +func scanWithProfile(root, packages, coverprofile string) ([]PackageCoverage, error) { + out, err := runGoTestHook(root, packages, coverprofile) + if err != nil { + return nil, fmt.Errorf("go test failed: %w\n%s", err, string(out)) + } + return parseGoTestCoverageOutput(string(out)) +} + +func filterGapsByPackage(gaps []Gap, pkg string) []Gap { + var out []Gap + for _, g := range gaps { + if strings.Contains(g.File, pkg) { + out = append(out, g) + } + } + return out +} diff --git a/cmd/sin-code/internal/coverdrohne/coverdrohne_test.go b/cmd/sin-code/internal/coverdrohne/coverdrohne_test.go new file mode 100644 index 00000000..cc464487 --- /dev/null +++ b/cmd/sin-code/internal/coverdrohne/coverdrohne_test.go @@ -0,0 +1,936 @@ +// SPDX-License-Identifier: MIT +// Purpose: coverage tests for the coverdrohne package (meta-coverage). +package coverdrohne + +import ( + "encoding/json" + "fmt" + "go/ast" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewScannerDefaults(t *testing.T) { + s := NewScanner() + if s.GoTest != "go" { + t.Errorf("GoTest default = %q, want go", s.GoTest) + } + if s.Packages != "./cmd/sin-code/..." { + t.Errorf("Packages default = %q, want ./cmd/sin-code/...", s.Packages) + } +} + +func TestScannerScan(t *testing.T) { + s := NewScanner() + s.runGoTest = func(dir, packages, coverprofile string) ([]byte, error) { + if packages != "./..." { + t.Errorf("packages = %q, want ./...", packages) + } + return []byte( + "ok github.com/example/a 0.010s coverage: 50.0% of statements\n" + + "ok github.com/example/b 0.020s coverage: 100.0% of statements\n" + + "ok github.com/example/c 0.030s coverage: 0.0% of statements\n"), + nil + } + s.Packages = "./..." + res, err := s.Scan() + if err != nil { + t.Fatal(err) + } + if len(res) != 3 { + t.Fatalf("len(res) = %d, want 3", len(res)) + } + want := []PackageCoverage{ + {ImportPath: "github.com/example/c", Coverage: 0}, + {ImportPath: "github.com/example/a", Coverage: 50}, + {ImportPath: "github.com/example/b", Coverage: 100}, + } + for i, r := range res { + if r.ImportPath != want[i].ImportPath || r.Coverage != want[i].Coverage { + t.Errorf("res[%d] = %+v, want %+v", i, r, want[i]) + } + } +} + +func TestScannerScanFailure(t *testing.T) { + s := NewScanner() + s.runGoTest = func(dir, packages, coverprofile string) ([]byte, error) { + return []byte("fail output"), &exitError{msg: "exit status 1"} + } + _, err := s.Scan() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "go test failed") { + t.Errorf("error = %q, want go test failed", err.Error()) + } +} + +func TestScannerScanVerbose(t *testing.T) { + s := NewScanner() + s.runGoTest = func(dir, packages, coverprofile string) ([]byte, error) { + return []byte("ok github.com/example/a 0.010s coverage: 75.0% of statements\n"), nil + } + s.Verbose = true + _, err := s.Scan() + if err != nil { + t.Fatal(err) + } +} + +type exitError struct { + msg string +} + +func (e *exitError) Error() string { return e.msg } + +// repoRoot returns the repository root by walking up from the test's working +// directory until a go.mod file is found. +func repoRoot() string { + dir, _ := os.Getwd() + for dir != "" && dir != "/" { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "." +} + +func TestParseGoTestCoverageOutput(t *testing.T) { + out := "ok github.com/example/a 0.010s coverage: 75.5% of statements\n" + + "ok github.com/example/b 0.020s coverage: 100.0% of statements\n" + + "some noise line\n" + res, err := parseGoTestCoverageOutput(out) + if err != nil { + t.Fatal(err) + } + if len(res) != 2 { + t.Fatalf("len = %d, want 2", len(res)) + } + if res[0].ImportPath != "github.com/example/a" || res[0].Coverage != 75.5 { + t.Errorf("res[0] = %+v", res[0]) + } + if res[1].ImportPath != "github.com/example/b" || res[1].Coverage != 100.0 { + t.Errorf("res[1] = %+v", res[1]) + } +} + +func TestModulePath(t *testing.T) { + dir := t.TempDir() + if got := modulePath(dir); got != "" { + t.Errorf("missing go.mod = %q, want empty", got) + } + _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module github.com/example/mod\n\ngo 1.23\n"), 0o644) + if got := modulePath(dir); got != "github.com/example/mod" { + t.Errorf("modulePath = %q, want github.com/example/mod", got) + } + _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module 'github.com/example/quoted'\n"), 0o644) + if got := modulePath(dir); got != "github.com/example/quoted" { + t.Errorf("quoted modulePath = %q, want github.com/example/quoted", got) + } +} + +func TestProfileFileToLocal(t *testing.T) { + root := "/project" + mod := "github.com/example/mod" + if got := profileFileToLocal(root, mod, "github.com/example/mod/foo/bar.go"); got != "/project/foo/bar.go" { + t.Errorf("module path = %q, want /project/foo/bar.go", got) + } + if got := profileFileToLocal(root, "", "foo/bar.go"); got != "/project/foo/bar.go" { + t.Errorf("relative path = %q, want /project/foo/bar.go", got) + } + if got := profileFileToLocal(root, "", "/abs/foo/bar.go"); got != "/abs/foo/bar.go" { + t.Errorf("absolute path = %q, want /abs/foo/bar.go", got) + } +} + +func TestGaps(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module github.com/example/mod\n\ngo 1.23\n"), 0o644) + srcDir := filepath.Join(dir, "pkg") + _ = os.MkdirAll(srcDir, 0o755) + _ = os.WriteFile(filepath.Join(srcDir, "foo.go"), []byte(`package pkg + +func Foo() { + if true { + _ = 1 + } + Bar() +} + +func Bar() { + _ = 2 +} +`), 0o644) + + profile := filepath.Join(dir, "coverage.out") + data := "mode: set\n" + + "github.com/example/mod/pkg/foo.go:3.12,8.2 1 1\n" + + "github.com/example/mod/pkg/foo.go:7.2,7.8 1 0\n" + + "github.com/example/mod/pkg/foo.go:10.12,12.2 1 0\n" + _ = os.WriteFile(profile, []byte(data), 0o644) + + gaps, err := Gaps(profile, dir) + if err != nil { + t.Fatal(err) + } + if len(gaps) != 1 { + t.Fatalf("len(gaps) = %d, want 1", len(gaps)) + } + g := gaps[0] + if g.File != "pkg/foo.go" { + t.Errorf("file = %q, want pkg/foo.go", g.File) + } + if len(g.Blocks) != 2 { + t.Fatalf("len(blocks) = %d, want 2", len(g.Blocks)) + } + if g.Blocks[0].FuncName != "Foo" { + t.Errorf("func[0] = %q, want Foo", g.Blocks[0].FuncName) + } + if g.Blocks[1].FuncName != "Bar" { + t.Errorf("func[1] = %q, want Bar", g.Blocks[1].FuncName) + } +} + +func TestGapsInvalidProfile(t *testing.T) { + dir := t.TempDir() + profile := filepath.Join(dir, "coverage.out") + _ = os.WriteFile(profile, []byte("not a mode line\n"), 0o644) + _, err := Gaps(profile, dir) + if err == nil { + t.Fatal("expected error") + } +} + +func TestParseProfileLine(t *testing.T) { + b, err := parseProfileLine("github.com/example/mod/pkg/foo.go:10.12,12.2 1 0") + if err != nil { + t.Fatal(err) + } + if b.File != "github.com/example/mod/pkg/foo.go" || b.StartLine != 10 || b.EndLine != 12 || b.NumStmts != 1 || b.Count != 0 { + t.Errorf("block = %+v", b) + } + _, err = parseProfileLine("bad") + if err == nil { + t.Fatal("expected error for bad line") + } + _, err = parseProfileLine("file:1.2,3.4 abc 0") + if err == nil { + t.Fatal("expected error for bad stmt count") + } + _, err = parseProfileLine("file:1.2,3.4 1 xyz") + if err == nil { + t.Fatal("expected error for bad count") + } +} + +func TestParseFilePos(t *testing.T) { + file, start, end, err := parseFilePos("foo/bar.go:1.2,3.4") + if err != nil || file != "foo/bar.go" || start != 1 || end != 3 { + t.Errorf("got %q %d %d %v", file, start, end, err) + } + _, _, _, err = parseFilePos("bad") + if err == nil { + t.Fatal("expected error") + } + _, _, _, err = parseFilePos("foo:1,2,3") + if err == nil { + t.Fatal("expected error") + } +} + +func TestLineFromPos(t *testing.T) { + v, err := lineFromPos("12.34") + if err != nil || v != 12 { + t.Errorf("lineFromPos = %d, want 12", v) + } + v, err = lineFromPos("56") + if err != nil || v != 56 { + t.Errorf("lineFromPos = %d, want 56", v) + } + _, err = lineFromPos("abc") + if err == nil { + t.Fatal("expected error") + } +} + +func TestFuncNameForBlock(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "foo.go"), []byte(`package pkg + +func Foo() {} +func (s *Store) Bar() {} +type T struct{} +func (t T) Baz() {} +`), 0o644) + n, err := funcNameForBlock(dir, filepath.Join(dir, "foo.go"), 3) + if err != nil || n != "Foo" { + t.Errorf("Foo = %q %v, want Foo", n, err) + } + n, err = funcNameForBlock(dir, filepath.Join(dir, "foo.go"), 4) + if err != nil || n != "(Store).Bar" { + t.Errorf("Bar = %q %v, want (Store).Bar", n, err) + } + n, err = funcNameForBlock(dir, filepath.Join(dir, "foo.go"), 6) + if err != nil || n != "(T).Baz" { + t.Errorf("Baz = %q %v, want (T).Baz", n, err) + } +} + +func TestFuncDeclName(t *testing.T) { + // Cannot test funcDeclName directly without a real AST, so we test via funcNameForBlock. + n, _ := funcNameForBlock(t.TempDir(), filepath.Join("does", "not", "exist.go"), 1) + if n != "" { + t.Errorf("missing file = %q, want empty", n) + } +} + +func TestNewCommand(t *testing.T) { + cmd := NewCommand() + if cmd.Name() != "cover" { + t.Errorf("name = %q, want cover", cmd.Name()) + } + if cmd.Commands() == nil || len(cmd.Commands()) == 0 { + t.Fatal("expected subcommands") + } +} + +func TestScanCmd(t *testing.T) { + cmd := newScanCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestScanCmdJSON(t *testing.T) { + var buf strings.Builder + cmd := newScanCmd() + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var out []PackageCoverage + if err := json.Unmarshal([]byte(buf.String()), &out); err != nil { + t.Fatal(err) + } +} + +func TestCheckCmdPass(t *testing.T) { + var buf strings.Builder + cmd := newCheckCmd() + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--min", "0"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "all packages") { + t.Errorf("output = %q", buf.String()) + } +} + +func TestCheckCmdFail(t *testing.T) { + cmd := newCheckCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/instinct", "--min", "100"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestGapsCmd(t *testing.T) { + cmd := newGapsCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/instinct", "--package", "instinct"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestGapsCmdJSON(t *testing.T) { + var buf strings.Builder + cmd := newGapsCmd() + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/instinct", "--package", "instinct", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var out []Gap + if err := json.Unmarshal([]byte(buf.String()), &out); err != nil { + t.Fatal(err) + } +} + +func TestGenerateCmd(t *testing.T) { + var buf strings.Builder + cmd := newGenerateCmd() + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/instinct", "--package", "instinct"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal([]byte(buf.String()), &out); err != nil { + t.Fatal(err) + } +} + +func TestGenerateCmdRequiresPackage(t *testing.T) { + cmd := newGenerateCmd() + cmd.SetOut(&strings.Builder{}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error without --package") + } +} + +func TestGenerateCmdOutFile(t *testing.T) { + out := filepath.Join(t.TempDir(), "req.json") + cmd := newGenerateCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/instinct", "--package", "instinct", "--out", out}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(out); err != nil { + t.Fatal(err) + } +} + +func TestGenerateCmdNotFound(t *testing.T) { + cmd := newGenerateCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/instinct", "--package", "nonexistent-package-xyz"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestHookCmd(t *testing.T) { + var buf strings.Builder + cmd := newHookCmd() + cmd.SetOut(&buf) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "sin-code cover check") { + t.Errorf("hook = %q", out) + } + if !strings.Contains(out, "#!/bin/sh") { + t.Errorf("missing shebang") + } +} + +func TestHookCmdInstall(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + _ = os.MkdirAll(gitDir, 0o755) + cmd := newHookCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--install", "--min", "80"}) + // The hook writes to .git/hooks relative to cwd, so switch to the temp dir. + old, _ := os.Getwd() + _ = os.Chdir(dir) + defer os.Chdir(old) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(gitDir, "hooks", "pre-commit")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "80.0") { + t.Errorf("installed hook = %q", string(data)) + } +} + +func TestHookCmdInstallMkdirError(t *testing.T) { + mkdirAllHook = func(path string, perm os.FileMode) error { + return fmt.Errorf("mkdir err") + } + defer func() { mkdirAllHook = os.MkdirAll }() + cmd := newHookCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--install"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "mkdir err") { + t.Fatalf("error = %v", err) + } +} + +func TestHookCmdInstallWriteError(t *testing.T) { + mkdirAllHook = func(path string, perm os.FileMode) error { return nil } + defer func() { mkdirAllHook = os.MkdirAll }() + writeFileModeHook = func(name string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write err") + } + defer func() { + writeFileModeHook = func(name string, data []byte, perm os.FileMode) error { return os.WriteFile(name, data, perm) } + }() + cmd := newHookCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--install"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "write err") { + t.Fatalf("error = %v", err) + } +} + +func TestFilterGapsByPackage(t *testing.T) { + gaps := []Gap{ + {File: "a/b.go"}, + {File: "c/d.go"}, + } + filtered := filterGapsByPackage(gaps, "c") + if len(filtered) != 1 || filtered[0].File != "c/d.go" { + t.Errorf("filtered = %+v", filtered) + } +} + +func TestScanDefaults(t *testing.T) { + mkdirTemp = func(dir, pattern string) (string, error) { + return "", fmt.Errorf("mkdir err") + } + defer func() { mkdirTemp = os.MkdirTemp }() + s := &Scanner{} + if _, err := s.Scan(); err == nil || !strings.Contains(err.Error(), "mkdir err") { + t.Fatalf("error = %v", err) + } +} + +func TestGapsOpenError(t *testing.T) { + openFileHook = func(name string) (*os.File, error) { + return nil, fmt.Errorf("open err") + } + defer func() { openFileHook = os.Open }() + _, err := Gaps("x.out", ".") + if err == nil || !strings.Contains(err.Error(), "open err") { + t.Fatalf("error = %v", err) + } +} + +func TestGapsScannerError(t *testing.T) { + dir := t.TempDir() + profile := filepath.Join(dir, "coverage.out") + data := "mode: set\n" + strings.Repeat("x", 1<<20) + "\n" + _ = os.WriteFile(profile, []byte(data), 0o644) + openFileHook = func(name string) (*os.File, error) { + return os.Open(profile) + } + defer func() { openFileHook = os.Open }() + _, err := Gaps(profile, ".") + if err == nil { + t.Fatal("expected error") + } +} + +func TestModulePathReadError(t *testing.T) { + readFileHook = func(name string) ([]byte, error) { + return nil, fmt.Errorf("read err") + } + defer func() { readFileHook = os.ReadFile }() + if got := modulePath("."); got != "" { + t.Errorf("modulePath = %q, want empty", got) + } +} + +func TestGapsModulePath(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module github.com/example/mod\n\ngo 1.23\n"), 0o644) + srcDir := filepath.Join(dir, "pkg") + _ = os.MkdirAll(srcDir, 0o755) + _ = os.WriteFile(filepath.Join(srcDir, "foo.go"), []byte("package pkg\n\nfunc Foo() {}\n"), 0o644) + + profile := filepath.Join(dir, "coverage.out") + data := "mode: set\n" + "github.com/example/mod/pkg/foo.go:1.12,3.2 1 0\n" + _ = os.WriteFile(profile, []byte(data), 0o644) + + gaps, err := Gaps(profile, dir) + if err != nil { + t.Fatal(err) + } + if len(gaps) != 1 || gaps[0].File != "pkg/foo.go" { + t.Errorf("gaps = %+v", gaps) + } +} + +func TestCheckCmdJSON(t *testing.T) { + var buf strings.Builder + cmd := newCheckCmd() + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--min", "0", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal([]byte(buf.String()), &out); err != nil { + t.Fatal(err) + } + if passed, _ := out["passed"].(bool); !passed { + t.Errorf("passed = false") + } +} + +func TestCheckCmdJSONFail(t *testing.T) { + var buf strings.Builder + cmd := newCheckCmd() + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/instinct", "--min", "100", "--json"}) + if err := cmd.Execute(); err != nil { + // JSON mode returns the failed list rather than an error so CI can consume it. + t.Fatalf("json check should not error: %v", err) + } + var out map[string]any + if err := json.Unmarshal([]byte(buf.String()), &out); err != nil { + t.Fatal(err) + } + if passed, _ := out["passed"].(bool); passed { + t.Errorf("passed = true") + } + if failed, ok := out["failed"].([]any); !ok || len(failed) == 0 { + t.Errorf("failed = %v", out["failed"]) + } +} + +func TestGenerateCmdWriteFileError(t *testing.T) { + writeFileHook = func(name string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write err") + } + defer func() { writeFileHook = os.WriteFile }() + out := filepath.Join(t.TempDir(), "req.json") + cmd := newGenerateCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/instinct", "--package", "instinct", "--out", out}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "write err") { + t.Fatalf("error = %v", err) + } +} + +func TestRunCoverageProfileMkdirError(t *testing.T) { + mkdirTempCmd = func(dir, pattern string) (string, error) { + return "", fmt.Errorf("mkdir err") + } + defer func() { mkdirTempCmd = os.MkdirTemp }() + _, err := runCoverageProfile(repoRoot(), "./cmd/sin-code/internal/wiring") + if err == nil || !strings.Contains(err.Error(), "mkdir err") { + t.Fatalf("error = %v", err) + } +} + +func TestRunCoverageProfileTestError(t *testing.T) { + mkdirTempCmd = func(dir, pattern string) (string, error) { + return t.TempDir(), nil + } + defer func() { mkdirTempCmd = os.MkdirTemp }() + runGoTestHook = func(dir, packages, coverprofile string) ([]byte, error) { + return []byte("fail"), &exitError{msg: "boom"} + } + defer func() { runGoTestHook = defaultRunGoTest }() + _, err := runCoverageProfile(repoRoot(), "./cmd/sin-code/internal/wiring") + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("error = %v", err) + } +} + +func TestScanWithProfileError(t *testing.T) { + runGoTestHook = func(dir, packages, coverprofile string) ([]byte, error) { + return []byte("fail"), &exitError{msg: "boom"} + } + defer func() { runGoTestHook = defaultRunGoTest }() + _, err := scanWithProfile(repoRoot(), "./cmd/sin-code/internal/wiring", "x.out") + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("error = %v", err) + } +} + +func TestParseGoTestCoverageOutputBadFields(t *testing.T) { + _, err := parseGoTestCoverageOutput("ok pkg 0.010s coverage: 1.2.3% of statements\n") + if err == nil { + t.Fatal("expected error") + } + _, err = parseGoTestCoverageOutput("ok pkg 0.010s coverage: abc% of statements\n") + if err != nil { + // regex does not match, so returns empty results without error. + t.Fatalf("non-matching line should not error: %v", err) + } +} + +func TestParseGoTestCoverageOutputScannerError(t *testing.T) { + big := strings.Repeat("x", 1<<20) + _, err := parseGoTestCoverageOutput("ok " + big + " coverage: 50.0% of statements\n") + if err == nil { + t.Fatal("expected error") + } +} + +func TestParseGoTestCoverageOutputParseError(t *testing.T) { + _, err := parseGoTestCoverageOutput("ok pkg 0.010s coverage: 1.2.3% of statements\n") + if err == nil { + t.Fatal("expected error") + } +} + +func TestFuncDeclNameGeneric(t *testing.T) { + _ = funcDeclName // silence unused warning if not reached + if _, ok := interface{}(funcDeclName).(func(*ast.FuncDecl) string); !ok { + t.Fatal("funcDeclName signature mismatch") + } +} + +func TestFuncDeclNameGenericReceivers(t *testing.T) { + cases := []struct { + code string + line int + want string + }{ + {"package pkg\n\ntype G[T any] struct{}\n\nfunc (g *G[T]) Foo() {}\n", 5, "(G).Foo"}, + {"package pkg\n\ntype G[T any] struct{}\n\nfunc (g G[T]) Bar() {}\n", 5, "(G).Bar"}, + } + for _, c := range cases { + dir := t.TempDir() + f := filepath.Join(dir, "foo.go") + _ = os.WriteFile(f, []byte(c.code), 0o644) + got, _ := funcNameForBlock(dir, f, c.line) + if got != c.want { + t.Errorf("line %d: got %q, want %q", c.line, got, c.want) + } + } +} + +func TestFuncDeclNameEmptyRecv(t *testing.T) { + // Create an AST with a receiver whose Type is unknown so recv stays empty. + dir := t.TempDir() + f := filepath.Join(dir, "foo.go") + _ = os.WriteFile(f, []byte("package pkg\n\ntype T struct{}\n\nfunc (t.T) Baz() {}\n"), 0o644) + got, _ := funcNameForBlock(dir, f, 5) + if got != "Baz" { + t.Errorf("got %q, want Baz", got) + } +} + +func TestModulePathNoModuleLine(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("go 1.23\n"), 0o644) + if got := modulePath(dir); got != "" { + t.Errorf("modulePath = %q, want empty", got) + } +} + +func TestParseFilePosLineFromPosError(t *testing.T) { + _, _, _, err := parseFilePos("file:abc.1,2.3") + if err == nil { + t.Fatal("expected error") + } + _, _, _, err = parseFilePos("file:1.2,xyz.3") + if err == nil { + t.Fatal("expected error") + } +} + +func TestParseProfileLineInvalidFilePos(t *testing.T) { + _, err := parseProfileLine("badfile:1.2,xyz 1 0") + if err == nil { + t.Fatal("expected error") + } +} + +func TestGapsEmptySrcRoot(t *testing.T) { + dir := t.TempDir() + profile := filepath.Join(dir, "coverage.out") + data := "mode: set\n" + "foo.go:1.12,3.2 1 0\n" + _ = os.WriteFile(profile, []byte(data), 0o644) + + gaps, err := Gaps(profile, "") + if err != nil { + t.Fatal(err) + } + if len(gaps) != 1 || gaps[0].File != "foo.go" { + t.Errorf("gaps = %+v", gaps) + } +} + +func TestGapsRelEmpty(t *testing.T) { + absFile := filepath.Join(t.TempDir(), "foo.go") + _ = os.WriteFile(absFile, []byte("package pkg\n\nfunc Foo() {}\n"), 0o644) + + profile := filepath.Join(t.TempDir(), "coverage.out") + data := "mode: set\n" + absFile + ":3.12,5.2 1 0\n" + _ = os.WriteFile(profile, []byte(data), 0o644) + + gaps, err := Gaps(profile, "") + if err != nil { + t.Fatal(err) + } + if len(gaps) != 1 || gaps[0].File != absFile { + t.Errorf("gaps = %+v", gaps) + } +} + +func TestScanCmdError(t *testing.T) { + mkdirTemp = func(dir, pattern string) (string, error) { + return "", fmt.Errorf("mkdir err") + } + defer func() { mkdirTemp = os.MkdirTemp }() + cmd := newScanCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestCheckCmdScanError(t *testing.T) { + mkdirTemp = func(dir, pattern string) (string, error) { + return "", fmt.Errorf("mkdir err") + } + defer func() { mkdirTemp = os.MkdirTemp }() + cmd := newCheckCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--min", "0"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestGapsCmdRunCoverageError(t *testing.T) { + mkdirTempCmd = func(dir, pattern string) (string, error) { + return "", fmt.Errorf("mkdir err") + } + defer func() { mkdirTempCmd = os.MkdirTemp }() + cmd := newGapsCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--package", "wiring"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestGapsCmdGapsError(t *testing.T) { + mkdirTempCmd = func(dir, pattern string) (string, error) { + return t.TempDir(), nil + } + defer func() { mkdirTempCmd = os.MkdirTemp }() + runGoTestHook = func(dir, packages, coverprofile string) ([]byte, error) { + _ = os.WriteFile(coverprofile, []byte("mode: set\nbad line\n"), 0o644) + return []byte("ok pkg coverage: 50.0% of statements\n"), nil + } + defer func() { runGoTestHook = defaultRunGoTest }() + cmd := newGapsCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--package", "wiring"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestGapsParseProfileLineError(t *testing.T) { + dir := t.TempDir() + profile := filepath.Join(dir, "coverage.out") + _ = os.WriteFile(profile, []byte("mode: set\nbad line\n"), 0o644) + _, err := Gaps(profile, dir) + if err == nil { + t.Fatal("expected error") + } +} + +func TestGenerateCmdRunCoverageError(t *testing.T) { + mkdirTempCmd = func(dir, pattern string) (string, error) { + return "", fmt.Errorf("mkdir err") + } + defer func() { mkdirTempCmd = os.MkdirTemp }() + cmd := newGenerateCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--package", "wiring"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestGenerateCmdScanWithProfileError(t *testing.T) { + mkdirTempCmd = func(dir, pattern string) (string, error) { + return t.TempDir(), nil + } + defer func() { mkdirTempCmd = os.MkdirTemp }() + runGoTestHook = func(dir, packages, coverprofile string) ([]byte, error) { + _ = os.WriteFile(coverprofile, []byte("mode: set\n"), 0o644) + return []byte("ok github.com/example/wiring 0.010s coverage: 50.0% of statements\n"), nil + } + defer func() { runGoTestHook = defaultRunGoTest }() + scanWithProfileHook = func(root, packages, coverprofile string) ([]PackageCoverage, error) { + return nil, fmt.Errorf("scan err") + } + defer func() { scanWithProfileHook = scanWithProfile }() + cmd := newGenerateCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--package", "wiring"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "scan err") { + t.Fatalf("error = %v", err) + } +} + +func TestGenerateCmdJSONMarshalError(t *testing.T) { + jsonMarshalIndentHook = func(v any, prefix, indent string) ([]byte, error) { + return nil, fmt.Errorf("marshal err") + } + defer func() { jsonMarshalIndentHook = json.MarshalIndent }() + cmd := newGenerateCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--package", "wiring"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "marshal err") { + t.Fatalf("error = %v", err) + } +} + +func TestGenerateCmdGapsError(t *testing.T) { + mkdirTempCmd = func(dir, pattern string) (string, error) { + return t.TempDir(), nil + } + defer func() { mkdirTempCmd = os.MkdirTemp }() + runGoTestHook = func(dir, packages, coverprofile string) ([]byte, error) { + _ = os.WriteFile(coverprofile, []byte("mode: set\nbad line\n"), 0o644) + return []byte("ok github.com/example/wiring 0.010s coverage: 50.0% of statements\n"), nil + } + defer func() { runGoTestHook = defaultRunGoTest }() + cmd := newGenerateCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetArgs([]string{"--root", repoRoot(), "--packages", "./cmd/sin-code/internal/wiring", "--package", "wiring"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error") + } +} + +func TestScanParseError(t *testing.T) { + mkdirTemp = func(dir, pattern string) (string, error) { + return t.TempDir(), nil + } + defer func() { mkdirTemp = os.MkdirTemp }() + s := &Scanner{} + s.runGoTest = func(dir, packages, coverprofile string) ([]byte, error) { + return []byte("ok pkg 0.010s coverage: 1.2.3% of statements\n"), nil + } + _, err := s.Scan() + if err == nil { + t.Fatal("expected error") + } +} + +func TestRunCoverageProfileDefaultPackages(t *testing.T) { + mkdirTempCmd = func(dir, pattern string) (string, error) { + return "", fmt.Errorf("mkdir err") + } + defer func() { mkdirTempCmd = os.MkdirTemp }() + _, err := runCoverageProfile(repoRoot(), "") + if err == nil || !strings.Contains(err.Error(), "mkdir err") { + t.Fatalf("error = %v", err) + } +} + diff --git a/cmd/sin-code/internal/coverdrohne/gap.go b/cmd/sin-code/internal/coverdrohne/gap.go new file mode 100644 index 00000000..6c6c98fd --- /dev/null +++ b/cmd/sin-code/internal/coverdrohne/gap.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT +// Purpose: gap analyzer — turns a Go coverage profile into a list of +// uncovered functions/blocks per package. +package coverdrohne + +import ( + "bufio" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" +) + +// readFileHook is swappable for tests that exercise the go.mod error path. +var readFileHook = os.ReadFile + +// openFileHook is swappable for tests that exercise the coverprofile open error path. +var openFileHook = os.Open + +// modulePath returns the module import path declared in go.mod at root. +func modulePath(root string) string { + data, err := readFileHook(filepath.Join(root, "go.mod")) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + fields := strings.Fields(line) + if len(fields) >= 2 { + return strings.Trim(fields[1], "\"'`") + } + } + } + return "" +} + +// profileFileToLocal converts a coverprofile file path (module import path) +// to a local filesystem path relative to root. +func profileFileToLocal(root, mod, file string) string { + if mod != "" && strings.HasPrefix(file, mod+"/") { + return filepath.Join(root, strings.TrimPrefix(file, mod+"/")) + } + if !filepath.IsAbs(file) { + return filepath.Join(root, file) + } + return file +} + +// Block is an uncovered or partially covered code block. +type Block struct { + File string `json:"file"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + FuncName string `json:"func_name"` + NumStmts int `json:"num_stmts"` + Count int `json:"count"` +} + +// Gap holds uncovered blocks for a single Go file. +type Gap struct { + File string `json:"file"` + Blocks []Block `json:"blocks"` +} + +// Gaps parses a coverage profile and returns uncovered blocks grouped by file. +// Only blocks with count == 0 are returned. srcRoot is the filesystem root used +// to resolve the file paths in the profile. +func Gaps(profilePath, srcRoot string) ([]Gap, error) { + f, err := openFileHook(profilePath) + if err != nil { + return nil, fmt.Errorf("open coverprofile: %w", err) + } + defer f.Close() + + mod := modulePath(srcRoot) + + var gaps []Gap + var currentGap *Gap + scanner := bufio.NewScanner(f) + first := true + for scanner.Scan() { + line := scanner.Text() + if first { + if !strings.HasPrefix(line, "mode: ") { + return nil, fmt.Errorf("invalid coverprofile: missing mode line") + } + first = false + continue + } + block, err := parseProfileLine(line) + if err != nil { + return nil, err + } + if block == nil || block.Count != 0 { + continue + } + localFile := profileFileToLocal(srcRoot, mod, block.File) + block.FuncName, _ = funcNameForBlock(srcRoot, localFile, block.StartLine) + + rel, _ := filepath.Rel(srcRoot, localFile) + if rel == "" { + rel = block.File + } + block.File = rel + + if currentGap == nil || currentGap.File != block.File { + currentGap = &Gap{File: block.File} + gaps = append(gaps, *currentGap) + currentGap = &gaps[len(gaps)-1] + } + currentGap.Blocks = append(currentGap.Blocks, *block) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return gaps, nil +} + +func parseProfileLine(line string) (*Block, error) { + parts := strings.SplitN(line, " ", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid profile line: %q", line) + } + filePos := parts[0] + numStmts, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid statement count in profile line: %q", line) + } + count, err := strconv.Atoi(parts[2]) + if err != nil { + return nil, fmt.Errorf("invalid count in profile line: %q", line) + } + file, startLine, endLine, err := parseFilePos(filePos) + if err != nil { + return nil, err + } + return &Block{ + File: file, + StartLine: startLine, + EndLine: endLine, + NumStmts: numStmts, + Count: count, + }, nil +} + +func parseFilePos(filePos string) (file string, startLine, endLine int, err error) { + colonIdx := strings.LastIndex(filePos, ":") + if colonIdx < 0 { + return "", 0, 0, fmt.Errorf("invalid file position: %q", filePos) + } + file = filePos[:colonIdx] + pos := filePos[colonIdx+1:] + posParts := strings.Split(pos, ",") + if len(posParts) != 2 { + return "", 0, 0, fmt.Errorf("invalid position range: %q", pos) + } + startLine, err = lineFromPos(posParts[0]) + if err != nil { + return "", 0, 0, err + } + endLine, err = lineFromPos(posParts[1]) + if err != nil { + return "", 0, 0, err + } + return file, startLine, endLine, nil +} + +func lineFromPos(pos string) (int, error) { + dotIdx := strings.Index(pos, ".") + if dotIdx < 0 { + return strconv.Atoi(pos) + } + return strconv.Atoi(pos[:dotIdx]) +} + +func funcNameForBlock(root, file string, line int) (string, error) { + abs := file + if !filepath.IsAbs(abs) { + abs = filepath.Join(root, file) + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, abs, nil, parser.ParseComments) + if err != nil { + return "", err + } + var name string + ast.Inspect(f, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.FuncDecl: + start := fset.Position(x.Pos()).Line + end := fset.Position(x.End()).Line + if start <= line && line <= end { + name = funcDeclName(x) + return false + } + case *ast.FuncLit: + start := fset.Position(x.Pos()).Line + end := fset.Position(x.End()).Line + if start <= line && line <= end { + name = "" + return false + } + } + return true + }) + return name, nil +} + +func funcDeclName(f *ast.FuncDecl) string { + if f.Recv == nil { + return f.Name.Name + } + var recv string + if len(f.Recv.List) > 0 { + t := f.Recv.List[0].Type + switch n := t.(type) { + case *ast.Ident: + recv = n.Name + case *ast.StarExpr: + switch m := n.X.(type) { + case *ast.Ident: + recv = m.Name + case *ast.IndexExpr: + if id, ok := m.X.(*ast.Ident); ok { + recv = id.Name + } + } + case *ast.IndexExpr: + if id, ok := n.X.(*ast.Ident); ok { + recv = id.Name + } + } + } + if recv == "" { + return f.Name.Name + } + return "(" + recv + ")." + f.Name.Name +} diff --git a/cmd/sin-code/internal/coverdrohne/scanner.go b/cmd/sin-code/internal/coverdrohne/scanner.go new file mode 100644 index 00000000..66d1a554 --- /dev/null +++ b/cmd/sin-code/internal/coverdrohne/scanner.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +// Purpose: coverage scanner for the SIN-Code Coverage-Drohne. +// Runs `go test` with coverage, parses the output, and exposes a structured +// report that can be used by CI gates or test-generation agents. +// Docs: cmd/sin-code/internal/coverdrohne/coverdrohne.doc.md +package coverdrohne + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// PackageCoverage holds the coverage result for one package. +// +// mkdirTemp is swappable for tests that exercise the temp-dir error path. +var mkdirTemp = os.MkdirTemp + +// PackageCoverage holds the coverage result for one package. +type PackageCoverage struct { + ImportPath string `json:"import_path"` + Coverage float64 `json:"coverage"` + Statements int `json:"statements"` + Covered int `json:"covered"` +} + +// Scanner configures how coverage is collected. +type Scanner struct { + // GoTest is the go binary to use (default: "go"). + GoTest string + // Root is the module root to scan from (default: current directory). + Root string + // Packages is the package pattern passed to `go test` (default: "./cmd/sin-code/..."). + Packages string + // Verbose prints the raw go test output to stderr. + Verbose bool + // runGoTest is the test hook; tests override it to avoid a real `go test` run. + runGoTest func(dir, packages, coverprofile string) ([]byte, error) +} + +// NewScanner returns a scanner with sane defaults. +func NewScanner() *Scanner { + return &Scanner{ + GoTest: "go", + Packages: "./cmd/sin-code/...", + } +} + +// Scan runs `go test -cover` for all configured packages and returns a sorted +// list of coverage results. The returned slice is sorted by coverage ascending. +func (s *Scanner) Scan() ([]PackageCoverage, error) { + goTest := s.GoTest + if goTest == "" { + goTest = "go" + } + root := s.Root + if root == "" { + root = "." + } + packages := s.Packages + if packages == "" { + packages = "./cmd/sin-code/..." + } + + run := s.runGoTest + if run == nil { + run = defaultRunGoTest + } + + // Use a temporary coverprofile for detailed gap analysis later. + tmpDir, err := mkdirTemp("", "sin-cover-drohne-*") + if err != nil { + return nil, fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + coverprofile := filepath.Join(tmpDir, "coverage.out") + + out, err := run(root, packages, coverprofile) + if err != nil { + return nil, fmt.Errorf("go test failed: %w\n%s", err, string(out)) + } + if s.Verbose { + os.Stderr.Write(out) + } + + results, err := parseGoTestCoverageOutput(string(out)) + if err != nil { + return nil, err + } + + return results, nil +} + +var coverageLineRe = regexp.MustCompile(`^ok\s+\S+\s+\S+\s+coverage:\s+([0-9.]+)%\s+of\s+statements`) + +func parseGoTestCoverageOutput(output string) ([]PackageCoverage, error) { + var results []PackageCoverage + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + m := coverageLineRe.FindStringSubmatch(line) + if m == nil { + continue + } + // Package path is the second token after "ok". + fields := strings.Fields(line) + pkg := fields[1] + pct, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return nil, fmt.Errorf("parse coverage percentage for %s: %w", pkg, err) + } + results = append(results, PackageCoverage{ + ImportPath: pkg, + Coverage: pct, + }) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + sort.Slice(results, func(i, j int) bool { return results[i].Coverage < results[j].Coverage }) + return results, nil +} + +func defaultRunGoTest(dir, packages, coverprofile string) ([]byte, error) { + cmd := exec.Command("go", "test", packages, "-count=1", "-p=1", "-coverprofile="+coverprofile) + cmd.Dir = dir + return cmd.CombinedOutput() +} diff --git a/cmd/sin-code/internal/permission/permission_test.go b/cmd/sin-code/internal/permission/permission_test.go index ce9072f3..55af8103 100644 --- a/cmd/sin-code/internal/permission/permission_test.go +++ b/cmd/sin-code/internal/permission/permission_test.go @@ -114,3 +114,20 @@ func TestEngine_ModeYoloBypassesAsk(t *testing.T) { t.Errorf("yolo: Ask should resolve to Allow, got %s", e.Check("Bash")) } } + +func TestPolicyString(t *testing.T) { + cases := []struct { + p Policy + want string + }{ + {Allow, "allow"}, + {Ask, "ask"}, + {Deny, "deny"}, + {Policy(99), "deny"}, + } + for _, c := range cases { + if got := c.p.String(); got != c.want { + t.Errorf("%v.String() = %q, want %q", c.p, got, c.want) + } + } +} diff --git a/cmd/sin-code/main.go b/cmd/sin-code/main.go index bda2b42a..cc7c9923 100644 --- a/cmd/sin-code/main.go +++ b/cmd/sin-code/main.go @@ -32,7 +32,8 @@ It consolidates 44+ subcommands into a single cobra-based CLI: Advanced tools: ibd, poc, sckg, adw, oracle, efm Utility commands: security, sbom, config, self-update, tui, serve, update Agent ecosystem: chat, sessions, mcp, goal, daemon, skill, superpowers, - vane, stack, gh, hub, ledger, summary, install, compress + vane, stack, gh, hub, ledger, summary, install, compress, + cover Other: completion, read, write, edit, lsp, plugin, index, orchestrator-run, orchestrator-agents, orchestrator-plan, todo, notifications, memory, assets, evalset, hooks, @@ -86,7 +87,7 @@ func init() { NewReviewCmd(), // v3.19.0 — review --complexity (issue #179) NewSkillsCmd(), // bundled project-local agent skills NewEvalCmd(), NewTraceCmd(), // v3.18.0: Eval & Observability System (issue #75) - NewProfileCmd(), // v3.18.0: single-source-of-truth per-agent profile renderer (issue #175) + NewProfileCmd(), // v3.18.0 — single-source-of-truth per-agent profile renderer (issue #175) NewRtkCmd(), // rtk (Rust Token Killer) bridge (issue #123) NewCodeGraphCmd(), // CodeGraph multi-language analysis bridge (issue #126) NewSpecCmd(), // Spec-Layer: *.spec.md contracts (issue #122) @@ -95,11 +96,12 @@ func init() { NewCatalogCmd(), // v3.18.0 — unified tool catalog (issue #163, supersedes hub + assets) NewCompileSpecCmd(), // v3.21.0 — declarative .sin-code.yml compiler (issue #164) NewGrillCmd(), // v3.18.0 — native adversarial design-review (issue #141 fusion) - NewSubagentCmd(), // v3.18.0 — isolated-context sub-agent (issue #192, wraps #153) - NewAutoPRCmd(), // v3.18.0 — self-healing pipeline (issue #158) + NewSubagentCmd(), // v3.18.0 — isolated-context sub-agent (issue #192, wraps #153) + NewAutoPRCmd(), // v3.18.0 — self-healing pipeline (issue #158) NewCheckpointCmd(), NewRewindCmd(), // v3.20.0 — workspace checkpointing + rewind (issue #194) NewDebtCmd(), // v3.18.0 — sin-debt marker manager (issue #177) NewAuditCmd(), NewCEOAUDITCmd(), // v3.18.0 — complexity audit (issue #180) + 48-gate CEO audit + NewCoverCmd(), // Coverage-Drohne: scan, check, gaps, generate, hook internal.InstinctCmd, internal.HooksCmd, internal.AssetsCmd, internal.EvalCmd, internal.PRPCmd, // continuous learning + lifecycle hooks + asset harvest + evalset + prp workflow ) diff --git a/cmd/sin-code/testdata/scripts/golden_help.txt b/cmd/sin-code/testdata/scripts/golden_help.txt index 8cf53bab..086bf37b 100644 --- a/cmd/sin-code/testdata/scripts/golden_help.txt +++ b/cmd/sin-code/testdata/scripts/golden_help.txt @@ -9,7 +9,8 @@ It consolidates 44+ subcommands into a single cobra-based CLI: Advanced tools: ibd, poc, sckg, adw, oracle, efm Utility commands: security, sbom, config, self-update, tui, serve, update Agent ecosystem: chat, sessions, mcp, goal, daemon, skill, superpowers, - vane, stack, gh, hub, ledger, summary, install, compress + vane, stack, gh, hub, ledger, summary, install, compress, + cover Other: completion, read, write, edit, lsp, plugin, index, orchestrator-run, orchestrator-agents, orchestrator-plan, todo, notifications, memory, assets, evalset, hooks, @@ -25,26 +26,35 @@ Usage: Available Commands: adw Architectural Debt Watchdogs — detect god modules, circular deps, etc. assets Manage harvested agents/commands/skills + audit Repo-wide audits (complexity, ...) auto Ultra-autonomous mode: pursue a program.md objective on your behalf autodev Bridge to OpenSIN-Code/autodev-cli (Python autoresearch loop, never vendored) + autodev Bridge to OpenSIN-Code/autodev-cli (Python autoresearch loop, never vendored) catalog Unified tool catalog (hub + assets, one CLI) + ceo-audit CEO-grade SOTA repository audit (48 gates) chat Run the SIN-Code agent loop (interactive REPL or headless one-shot) codegraph Bridge to CodeGraph for multi-language code analysis + codegraph Bridge to CodeGraph for multi-language code analysis + codegraph Bridge to CodeGraph for multi-language code analysis compile-spec Compile .sin-code.yml into the four derived JSON artifacts completion Generate the autocompletion script for the specified shell compress Lossless compaction for lessons / instincts / summaries / AGENTS.md config View and manage sin-code configuration + cover Coverage scanner and test-generation coordinator daemon Run the autonomous worker: lease goals, execute, verify, learn + debt Inspect sin-debt markers (issue #177) discover Discover files with relevance scoring and pattern matching dox Self-maintaining AGENTS.md hierarchy (agent0ai/dox protocol) edit Hashline-anchored surgical edits with validation efm Ephemeral Full-Stack Mocking — spin up disposable test environments eval Run Golden Dataset evaluation suites + eval Run Golden Dataset evaluation suites evalset Eval-driven development harness (EvalSets, Runs, regression compare) execute Execute shell commands safely with secret redaction and timeout gh Bridge to the GitHub CLI (gh) with a 3-tier verb-allowlist policy goal Manage the autonomous goal queue grasp Deep code understanding for a single file + grill Native adversarial design-review interview (issue #141 fusion) harvest Fetch URLs with caching, structure extraction, and change detection headroom Manage Headroom context compression integration help Help about any command @@ -55,6 +65,7 @@ Available Commands: install Install or verify the sin-code single-binary release instinct Manage learned instincts (continuous learning) ledger Query the semantic session ledger + ledger Query the semantic session ledger lsp LSP (Language Server Protocol) — IDE-grade code intelligence map Map code architecture with dependency graphs and hot-path analysis mcp Inspect and debug external MCP servers @@ -72,6 +83,8 @@ Available Commands: read Read files with hashline anchors, outline, and size guards review Review code for complexity and other quality dimensions rtk Bridge to rtk (Rust Token Killer) to cut LLM token usage 60-90% + rtk Bridge to rtk (Rust Token Killer) to cut LLM token usage 60-90% + rtk Bridge to rtk (Rust Token Killer) to cut LLM token usage 60-90% sbom Generate SPDX or CycloneDX JSON SBOM for a project sckg Semantic Codebase Knowledge Graphs — build & query code graph scout Search code with regex, semantic, symbol, and usage search @@ -82,12 +95,16 @@ Available Commands: skill Install and manage ecosystem MCP skills skills List and install bundled project-local skills spec Author, validate & inspect *.spec.md contracts (Spec-Layer) + spec Author, validate & inspect *.spec.md contracts (Spec-Layer) + spec Author, validate & inspect *.spec.md contracts (Spec-Layer) stack Manage the DOX + Superpowers + Vane methodology stack summary Build a deterministic summary from the session ledger + summary Build a deterministic summary from the session ledger superpowers Integrate obra/superpowers skills into SIN-Code swarm Race N agent profiles on the same prompt (first verified wins) todo Issue tracker with dependencies, audit log, and project namespaces trace Configure + verify OpenTelemetry tracer setup + trace Configure + verify OpenTelemetry tracer setup triage Read the open issue backlog via gh, score, group, and render tui Interactive multi-pane TUI (Tools, Sessions, EFM, Config, History) update Update SIN-Code stack (Python packages, Go binaries, skills)