Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
102 changes: 86 additions & 16 deletions cmd/mutate.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import (
)

var (
mutateCases string
mutateTarget string
mutateOutput string
mutateOperators string
mutateConcurrency int
mutateCases string
mutateTarget string
mutateOutput string
mutateOperators string
mutateReportFormat string
mutateConcurrency int
mutateOperatorConcurrency int
)

var mutateCmd = &cobra.Command{
Expand All @@ -48,26 +50,52 @@ Examples:

func init() {
rootCmd.AddCommand(mutateCmd)
mutateCmd.Flags().StringVar(&mutateCases, "cases", "", "Directory containing index.json and .hurl files (required)")
_ = mutateCmd.MarkFlagRequired("cases")
mutateCmd.Flags().StringVar(&mutateTarget, "target", "", "API base URL, e.g. http://localhost:8080 (required)")
_ = mutateCmd.MarkFlagRequired("target")
mutateCmd.Flags().StringVar(&mutateCases, "cases", "", "Directory containing index.json and .hurl files (required when not using --history)")
mutateCmd.Flags().StringVar(&mutateTarget, "target", "", "API base URL, e.g. http://localhost:8080 (required when not using --history)")
mutateCmd.Flags().StringVar(&mutateOutput, "output", "", "Directory to write mutation-report.json (optional)")
mutateCmd.Flags().StringVar(&mutateOperators, "operator", "", "Comma-separated operator names to run (default: all 12)")
mutateCmd.Flags().String("spec", "", "OpenAPI spec file (optional; passed to LLM in Phase 2)")
mutateCmd.Flags().IntVar(&mutateConcurrency, "concurrency", 4, "Number of cases processed concurrently per operator")
mutateCmd.Flags().IntVar(&mutateOperatorConcurrency, "operator-concurrency", 2, "Number of operators to run in parallel")
mutateCmd.Flags().StringVar(&mutateReportFormat, "report-format", "json", `Comma-separated report formats: json,markdown,html,all`)
mutateCmd.Flags().Bool("history", false, "Print mutation score history (does not run mutations; --target not required)")
mutateCmd.Flags().Int("history-limit", 10, "Maximum number of historical runs to display")
mutateCmd.Flags().Bool("feedback", false, "Run LLM feedback analysis on survivors (requires LLM provider in .caseforge.yaml)")
mutateCmd.Flags().Bool("auto-fix", false, "Patch index.json with suggested assertions (requires --feedback)")
mutateCmd.Flags().Bool("yes", false, "Skip confirmation prompt for --auto-fix")
}

func runMutate(cmd *cobra.Command, _ []string) error {
if historyFlag, _ := cmd.Flags().GetBool("history"); historyFlag {
limit, _ := cmd.Flags().GetInt("history-limit")
runs, err := mutation.LoadHistory("", limit)
if err != nil {
return fmt.Errorf("loading history: %w", err)
}
fmt.Fprint(cmd.OutOrStdout(), mutation.RenderHistory(runs))
return nil
}

// --cases and --target are required when not using --history
if mutateCases == "" {
return fmt.Errorf("required flag \"cases\" not set")
}
if mutateTarget == "" {
return fmt.Errorf("required flag \"target\" not set")
}

feedbackFlag, _ := cmd.Flags().GetBool("feedback")
autoFixFlag, _ := cmd.Flags().GetBool("auto-fix")
if autoFixFlag && !feedbackFlag {
return fmt.Errorf("--auto-fix requires --feedback")
}

// Validate --report-format unconditionally so a typo is always caught early.
reportFormats, err := parseReportFormats(mutateReportFormat)
if err != nil {
return err
}

ops, err := resolveOperators(mutateOperators)
if err != nil {
return err
Expand All @@ -77,10 +105,11 @@ func runMutate(cmd *cobra.Command, _ []string) error {
fmt.Fprintf(out, "Running %d operator(s) × cases in %s...\n", len(ops), mutateCases)

opts := mutation.RunOptions{
Target: mutateTarget,
CasesDir: mutateCases,
Operators: ops,
Concurrency: mutateConcurrency,
Target: mutateTarget,
CasesDir: mutateCases,
Operators: ops,
Concurrency: mutateConcurrency,
OperatorConcurrency: mutateOperatorConcurrency,
}

run, err := mutation.Run(opts)
Expand Down Expand Up @@ -146,11 +175,16 @@ func runMutate(cmd *cobra.Command, _ []string) error {
_ = mutation.Persist("", run)

if mutateOutput != "" {
if err := mutation.WriteReport(mutateOutput, run); err != nil {
if err := mutation.WriteReport(mutateOutput, run, reportFormats); err != nil {
return fmt.Errorf("writing report: %w", err)
}
fmt.Fprintf(cmd.ErrOrStderr(), "Report written to: %s\n",
filepath.Join(mutateOutput, "mutation-report.json"))
extMap := map[string]string{"json": ".json", "markdown": ".md", "html": ".html"}
for _, f := range reportFormats {
if ext, ok := extMap[f]; ok {
fmt.Fprintf(cmd.ErrOrStderr(), "Report written to: %s\n",
filepath.Join(mutateOutput, "mutation-report"+ext))
}
}
}

if run.Survivors > 0 {
Expand Down Expand Up @@ -222,3 +256,39 @@ func countSuggestions(items []mutation.FeedbackItem) int {
}
return n
}

// parseReportFormats splits the comma-separated format string, expands "all"
// to ["json", "markdown", "html"], and deduplicates. Returns ["json"] if empty.
// Returns an error for unrecognized format names.
func parseReportFormats(s string) ([]string, error) {
if s == "" {
return []string{"json"}, nil
}
seen := map[string]bool{}
var out []string
for fmtName := range strings.SplitSeq(s, ",") {
fmtName = strings.ToLower(strings.TrimSpace(fmtName))
if fmtName == "all" {
for _, fn := range []string{"json", "markdown", "html"} {
if !seen[fn] {
seen[fn] = true
out = append(out, fn)
}
}
} else if fmtName != "" {
switch fmtName {
case "json", "markdown", "html":
if !seen[fmtName] {
seen[fmtName] = true
out = append(out, fmtName)
}
default:
return nil, fmt.Errorf("unknown report format %q; valid: json, markdown, html, all", fmtName)
}
}
}
if len(out) == 0 {
return []string{"json"}, nil
}
return out, nil
}
67 changes: 39 additions & 28 deletions internal/mutation/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,45 +34,56 @@ func Run(opts RunOptions) (MutationRun, error) {
return MutationRun{}, fmt.Errorf("no test cases found in %s/index.json", opts.CasesDir)
}

proxy, err := NewProxy(opts.Target)
if err != nil {
return MutationRun{}, fmt.Errorf("starting proxy: %w", err)
operators := opts.Operators
if len(operators) == 0 {
operators = Registry()
}
defer proxy.Close()

var (
mu sync.Mutex
results []CaseMutationResult
)

operators := opts.Operators
if len(operators) == 0 {
operators = Registry()
}
opSem := make(chan struct{}, opts.OperatorConcurrency)
g, _ := errgroup.WithContext(context.Background())

for _, op := range operators {
proxy.SetActive(op)
g, _ := errgroup.WithContext(context.Background())
sem := make(chan struct{}, opts.Concurrency)

for _, tc := range cases {
g.Go(func() error {
sem <- struct{}{}
defer func() { <-sem }()
survived := runOnce(proxy.Addr(), opts.CasesDir, tc.ID)
mu.Lock()
results = append(results, CaseMutationResult{
CaseID: tc.ID,
Title: tc.Title,
Operator: op.Name(),
Survived: survived,
g.Go(func() error {
opSem <- struct{}{}
defer func() { <-opSem }()

proxy, err := NewProxy(opts.Target)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: operator %s: proxy start failed: %v\n", op.Name(), err)
return nil // skip this operator; don't abort others
}
defer proxy.Close()
proxy.SetActive(op)

caseSem := make(chan struct{}, opts.Concurrency)
cg, _ := errgroup.WithContext(context.Background())
for _, tc := range cases {
cg.Go(func() error {
caseSem <- struct{}{}
defer func() { <-caseSem }()
survived := runOnce(proxy.Addr(), opts.CasesDir, tc.ID)
mu.Lock()
results = append(results, CaseMutationResult{
CaseID: tc.ID,
Title: tc.Title,
Operator: op.Name(),
Survived: survived,
})
mu.Unlock()
return nil
})
mu.Unlock()
return nil
})
}
_ = g.Wait()
}
_ = cg.Wait()
return nil
})
}
// goroutines always return nil (proxy failures are logged and skipped)
_ = g.Wait()

killed, survivors := 0, 0
for _, r := range results {
Expand Down
54 changes: 54 additions & 0 deletions internal/mutation/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,57 @@ func TestEngineRun_NoHurlFiles(t *testing.T) {
t.Fatalf("expected 1 total run, got %d", run.TotalRuns)
}
}

func TestEngineRun_TwoOperatorsParallel(t *testing.T) {
if _, err := exec.LookPath("hurl"); err != nil {
t.Skip("hurl not installed")
}
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"id":1}`))
}))
defer backend.Close()

dir := t.TempDir()
buildTestHurlFile(t, dir, "TC-PAR", "GET {{base_url}}/\nHTTP 200\n")
buildTestIndexJSON(t, dir, "TC-PAR", "parallel test")

opts := mutation.RunOptions{
Target: backend.URL,
CasesDir: dir,
Operators: []mutation.Operator{mutation.NewFieldDropOperator(), mutation.NewStatusSwap2xxOperator()},
Concurrency: 1,
OperatorConcurrency: 2,
}
run, err := mutation.Run(opts)
if err != nil {
t.Fatal(err)
}
if run.TotalRuns != 2 {
t.Fatalf("expected 2 total runs (2 operators × 1 case), got %d", run.TotalRuns)
}
ops := map[string]bool{}
for _, r := range run.Results {
ops[r.Operator] = true
}
if !ops["field_drop"] || !ops["status_swap_2xx"] {
t.Error("both operators must appear in results when run with OperatorConcurrency=2")
}
}

func TestEngineRun_OperatorConcurrencyZeroDefault(t *testing.T) {
dir := t.TempDir()
buildTestIndexJSON(t, dir, "TC-DEF", "defaults test")
opts := mutation.RunOptions{
Target: "http://127.0.0.1:1",
CasesDir: dir,
Operators: []mutation.Operator{mutation.NewFieldDropOperator()},
Concurrency: 1,
OperatorConcurrency: 0, // must not panic; defaults to 2
}
_, err := mutation.Run(opts)
if err != nil {
t.Fatal(err) // missing hurl file is OK (returns killed), no panic expected
}
}
Loading
Loading