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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ caseforge lint --spec openapi.yaml

| Command | Description |
|---------|-------------|
| `mutate` | Run HTTP boundary mutations via a reverse proxy to find weak test assertions |
| `rbt` | Risk-based testing: assess which operations are at risk from recent git changes |
| `rbt index` | Auto-generate `caseforge-map.yaml` by analysing source code |
| `explore` | Dynamically probe a live API and infer implicit validation rules |
Expand Down Expand Up @@ -337,6 +338,42 @@ via `--data-pool` to seed realistic field values into generated chain probes.
--format string terminal | json (default: terminal)
```

### `caseforge mutate`

Run HTTP boundary mutations through a reverse proxy between hurl and your API. For each operator × test case combination, the proxy alters the response before hurl evaluates assertions. Cases where hurl still passes are **survivors** — mutations your assertions failed to catch.

Requires hurl on PATH and test cases previously generated with `caseforge gen`.

```
--cases string Directory containing index.json and .hurl files (required)
--target string API base URL, e.g. http://localhost:8080 (required)
--output string Directory to write mutation-report.json (optional)
--operator string Comma-separated operator names to run (default: all 12)
--concurrency int Cases processed concurrently per operator (default: 4)
--spec string OpenAPI spec file (passed to LLM for context with --feedback)
--feedback Run LLM analysis on survivors and suggest stronger assertions
--auto-fix Patch index.json with suggested assertions (requires --feedback)
--yes Skip confirmation prompt for --auto-fix
```

**12 operators:** `field_drop`, `field_type_swap`, `array_to_null`, `null_to_array`, `status_swap_2xx`, `error_inflation`, `pagination_off_by_one`, `empty_result_injection`, `content_type_swap`, `header_drop`, `date_format_swap`, `numeric_precision_loss`

Exit codes: `0` — no survivors; `6` — one or more mutations survived.

Each run is persisted to `.caseforge/mutation/runs/<timestamp>.json`.

```bash
# Run all 12 operators, write JSON report
caseforge mutate --cases ./cases --target http://localhost:8080 --output ./reports

# Run a specific operator only
caseforge mutate --cases ./cases --target http://localhost:8080 --operator field_drop

# Phase 2: LLM feedback + auto-fix (requires provider in .caseforge.yaml)
caseforge mutate --cases ./cases --target http://localhost:8080 \
--feedback --auto-fix --yes
```

### `caseforge sandbox`

Start a local HTTP mock server that serves realistic responses generated from an OpenAPI spec. Stop with Ctrl-C (SIGINT/SIGTERM triggers graceful shutdown).
Expand Down
224 changes: 224 additions & 0 deletions cmd/mutate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// cmd/mutate.go
package cmd

import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/testmind-hq/caseforge/internal/config"
"github.com/testmind-hq/caseforge/internal/llm"
"github.com/testmind-hq/caseforge/internal/mutation"
)

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

var mutateCmd = &cobra.Command{
Use: "mutate",
Short: "Run HTTP boundary mutations to find weak test assertions",
Long: `Mutate starts a local reverse proxy between hurl and your API.
For each mutation operator × test case, the proxy alters the response
before hurl evaluates assertions. Cases where hurl still passes are
"survivors" — mutations your assertions failed to catch.

Requires hurl on PATH. Test cases must be previously generated with 'caseforge gen'.

Exit codes:
0 — run complete, no survivors
6 — one or more mutations survived

Examples:
caseforge mutate --cases ./cases --target http://localhost:8080
caseforge mutate --cases ./cases --target http://localhost:8080 --output ./reports
caseforge mutate --cases ./cases --target http://localhost:8080 --operator field_drop,status_swap_2xx`,
RunE: runMutate,
SilenceUsage: true,
}

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(&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().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 {
feedbackFlag, _ := cmd.Flags().GetBool("feedback")
autoFixFlag, _ := cmd.Flags().GetBool("auto-fix")
if autoFixFlag && !feedbackFlag {
return fmt.Errorf("--auto-fix requires --feedback")
}

ops, err := resolveOperators(mutateOperators)
if err != nil {
return err
}

out := cmd.OutOrStdout()
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,
}

run, err := mutation.Run(opts)
if err != nil {
return fmt.Errorf("mutation run: %w", err)
}

run.Clusters = mutation.ClusterSurvivors(run)

if feedbackFlag && run.Survivors > 0 {
cfg, cfgErr := config.Load()
if cfgErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "warn: --feedback: failed to load config: %v\n", cfgErr)
} else {
provider := llm.NewProviderWithConfig(llm.ProviderConfig{
APIKey: cfg.AI.APIKey,
Provider: cfg.AI.Provider,
Model: cfg.AI.Model,
BaseURL: cfg.AI.BaseURL,
Region: cfg.AI.Region,
})
items, fbErr := mutation.Analyze(context.Background(), run, provider)
if fbErr == nil && len(items) > 0 {
run.Feedback = items
fmt.Fprintf(out, "\nFeedback (%d cases with weak assertions):\n", len(items))
for _, item := range items {
fmt.Fprintf(out, " ⚠ [%s] %s risk=%.2f → %d suggested assertion(s)\n",
item.CaseID, item.Title, item.RiskScore, len(item.SuggestedAssertions))
}
autoFix, _ := cmd.Flags().GetBool("auto-fix")
if autoFix {
yes, _ := cmd.Flags().GetBool("yes")
if err := runAutoFix(run, mutateCases, yes, out); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "auto-fix: %v\n", err)
}
} else {
fmt.Fprintln(out, "Run with --feedback --auto-fix to patch index.json")
}
}
}
}

for _, r := range run.Results {
if r.Survived {
color.New(color.FgRed).Fprintf(out, " ✗ [%s] %s — not caught\n", r.Operator, r.Title)
} else {
color.New(color.FgGreen).Fprintf(out, " ✓ [%s] %s — caught\n", r.Operator, r.Title)
}
}

pct := 0
if run.TotalRuns > 0 {
pct = int(run.MutationScore * 100)
}
if run.Survivors == 0 {
color.New(color.FgGreen).Fprintf(out, "\nMutation Score: %d/%d killed (%d%%) — no survivors\n",
run.Killed, run.TotalRuns, pct)
} else {
color.New(color.FgYellow).Fprintf(out, "\nMutation Score: %d/%d killed (%d%%)\nSurvivors: %d\n",
run.Killed, run.TotalRuns, pct, run.Survivors)
}

_ = mutation.Persist("", run)

if mutateOutput != "" {
if err := mutation.WriteReport(mutateOutput, run); err != nil {
return fmt.Errorf("writing report: %w", err)
}
fmt.Fprintf(cmd.ErrOrStderr(), "Report written to: %s\n",
filepath.Join(mutateOutput, "mutation-report.json"))
}

if run.Survivors > 0 {
os.Exit(ExitPartialSuccess)
}
return nil
}

func resolveOperators(filter string) ([]mutation.Operator, error) {
all := mutation.Registry()
if filter == "" {
return all, nil
}
names := strings.Split(filter, ",")
nameSet := map[string]bool{}
for _, n := range names {
nameSet[strings.TrimSpace(n)] = true
}
var ops []mutation.Operator
for _, op := range all {
if nameSet[op.Name()] {
ops = append(ops, op)
}
}
if len(ops) == 0 {
return nil, fmt.Errorf("no operators matched --operator %q; valid names: %s",
filter, operatorNames(all))
}
return ops, nil
}

func operatorNames(ops []mutation.Operator) string {
names := make([]string, len(ops))
for i, op := range ops {
names[i] = op.Name()
}
return strings.Join(names, ", ")
}

func runAutoFix(run mutation.MutationRun, casesDir string, skipConfirm bool, out io.Writer) error {
if len(run.Feedback) == 0 {
return nil
}

fmt.Fprintf(out, "\nAuto-fix will append %d assertion(s) across %d case(s).\n",
countSuggestions(run.Feedback), len(run.Feedback))

if !skipConfirm {
fmt.Fprint(out, "Apply? [y/N] ")
var answer string
fmt.Scanln(&answer)
if answer != "y" && answer != "Y" {
fmt.Fprintln(out, "Skipped.")
return nil
}
}

if err := mutation.PatchIndex(casesDir, run.Feedback); err != nil {
return fmt.Errorf("patching index.json: %w", err)
}
fmt.Fprintln(out, "index.json updated.")
return nil
}

func countSuggestions(items []mutation.FeedbackItem) int {
n := 0
for _, item := range items {
n += len(item.SuggestedAssertions)
}
return n
}
32 changes: 32 additions & 0 deletions cmd/mutate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// cmd/mutate_test.go
package cmd

import (
"bytes"
"testing"

"github.com/stretchr/testify/assert"
)

func TestMutateCmdRegistered(t *testing.T) {
found := false
for _, c := range rootCmd.Commands() {
if c.Name() == "mutate" {
found = true
break
}
}
assert.True(t, found, "mutate command must be registered on rootCmd")
}

func TestMutateCmdRequiresFlags(t *testing.T) {
buf := &bytes.Buffer{}
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"mutate"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("mutate without --cases and --target must return error")
}
rootCmd.SetArgs(nil)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
golang.org/x/tools v0.43.0
google.golang.org/genai v1.51.0
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -106,7 +107,6 @@ require (
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
Expand Down
Loading
Loading