diff --git a/cmd/mutate.go b/cmd/mutate.go index 175721a..c80fd44 100644 --- a/cmd/mutate.go +++ b/cmd/mutate.go @@ -17,12 +17,12 @@ import ( ) var ( - mutateCases string - mutateTarget string - mutateOutput string - mutateOperators string - mutateReportFormat string - mutateConcurrency int + mutateCases string + mutateTarget string + mutateOutput string + mutateOperators string + mutateReportFormat string + mutateConcurrency int mutateOperatorConcurrency int ) @@ -38,7 +38,7 @@ Requires hurl on PATH. Test cases must be previously generated with 'caseforge g Exit codes: 0 — run complete, no survivors - 6 — one or more mutations survived + 6 — one or more mutations survived, or --min-score threshold breached Examples: caseforge mutate --cases ./cases --target http://localhost:8080 @@ -60,6 +60,8 @@ func init() { 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().Float64("min-score", 0, + "Per-operation minimum mutation score (0.0–1.0); exit 6 if any operation scores below this") 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") @@ -96,6 +98,11 @@ func runMutate(cmd *cobra.Command, _ []string) error { return err } + minScore, _ := cmd.Flags().GetFloat64("min-score") + if minScore < 0 || minScore > 1.0 { + return fmt.Errorf("--min-score must be between 0.0 and 1.0, got %.2f", minScore) + } + ops, err := resolveOperators(mutateOperators) if err != nil { return err @@ -187,6 +194,23 @@ func runMutate(cmd *cobra.Command, _ []string) error { } } + if minScore > 0 { + var failing []mutation.OperationScore + for _, op := range run.OperationScores { + if op.MutationScore < minScore { + failing = append(failing, op) + } + } + if len(failing) > 0 { + color.New(color.FgRed).Fprintf(out, + "\n✗ %d operation(s) below --min-score %.0f%%:\n", len(failing), minScore*100) + for _, op := range failing { + fmt.Fprintf(out, " %-52s %3.0f%%\n", op.Operation, op.MutationScore*100) + } + os.Exit(ExitPartialSuccess) + } + } + if run.Survivors > 0 { os.Exit(ExitPartialSuccess) } diff --git a/internal/mutation/engine.go b/internal/mutation/engine.go index 61ffc2b..4bc0e26 100644 --- a/internal/mutation/engine.go +++ b/internal/mutation/engine.go @@ -19,6 +19,9 @@ type indexFile struct { TestCases []struct { ID string `json:"id"` Title string `json:"title"` + Source struct { + SpecPath string `json:"spec_path"` + } `json:"source"` } `json:"test_cases"` } @@ -69,10 +72,11 @@ func Run(opts RunOptions) (MutationRun, error) { 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, + CaseID: tc.ID, + Title: tc.Title, + Operation: tc.Operation, + Operator: op.Name(), + Survived: survived, }) mu.Unlock() return nil @@ -106,16 +110,17 @@ func Run(opts RunOptions) (MutationRun, error) { } return MutationRun{ - Target: opts.Target, - CasesDir: opts.CasesDir, - Operators: opNames, - TotalCases: len(cases), - TotalRuns: total, - Killed: killed, - Survivors: survivors, - MutationScore: score, - Results: results, - GeneratedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + Target: opts.Target, + CasesDir: opts.CasesDir, + Operators: opNames, + TotalCases: len(cases), + TotalRuns: total, + Killed: killed, + Survivors: survivors, + MutationScore: score, + Results: results, + OperationScores: ComputeOperationScores(results), + GeneratedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"), }, nil } @@ -152,8 +157,9 @@ func runOnce(proxyAddr, casesDir, caseID string) bool { } type caseRef struct { - ID string - Title string + ID string + Title string + Operation string // source.spec_path; "(unknown)" if absent } func loadCases(casesDir string) ([]caseRef, error) { @@ -167,7 +173,11 @@ func loadCases(casesDir string) ([]caseRef, error) { } refs := make([]caseRef, len(idx.TestCases)) for i, tc := range idx.TestCases { - refs[i] = caseRef{ID: tc.ID, Title: tc.Title} + op := tc.Source.SpecPath + if op == "" { + op = "(unknown)" + } + refs[i] = caseRef{ID: tc.ID, Title: tc.Title, Operation: op} } return refs, nil } diff --git a/internal/mutation/render.go b/internal/mutation/render.go index f08bad0..487dd75 100644 --- a/internal/mutation/render.go +++ b/internal/mutation/render.go @@ -19,6 +19,8 @@ func RenderMarkdown(run MutationRun) string { fmt.Fprintf(&b, "Mutation Score: **%d/%d killed (%d%%)** · Survivors: **%d**\n\n", run.Killed, run.TotalRuns, pct, run.Survivors) + b.WriteString(renderOperationTable(run)) + if len(run.Clusters) > 0 { b.WriteString("## Survivor Summary (by risk)\n\n") b.WriteString("| Case | Title | Risk | Survived Operators |\n") @@ -48,6 +50,57 @@ func RenderMarkdown(run MutationRun) string { return b.String() } +// renderOperationTable returns the per-operation Markdown table, or "" if no scores. +func renderOperationTable(run MutationRun) string { + if len(run.OperationScores) == 0 { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "## Per-Operation Mutation Score\n\n") + fmt.Fprintf(&b, "| Operation | Score | Killed | Survivors |\n") + fmt.Fprintf(&b, "|-----------|-------|--------|-----------|\n") + for _, op := range run.OperationScores { + pct := int(op.MutationScore * 100) + badge := "" + switch { + case op.MutationScore == 1.0: + badge = " ✓" + case op.MutationScore < 0.7: + badge = " ⚠" + } + fmt.Fprintf(&b, "| %s | %d%%%s | %d/%d | %d |\n", + op.Operation, pct, badge, op.Killed, op.TotalRuns, op.Survivors) + } + b.WriteString("\n") + return b.String() +} + +// renderOperationTableHTML returns the per-operation HTML table, or "" if no scores. +func renderOperationTableHTML(run MutationRun) string { + if len(run.OperationScores) == 0 { + return "" + } + var b strings.Builder + b.WriteString("
| Operation | Score | Killed | Survivors |
|---|---|---|---|
| %s | %d%% | %d/%d | %d |