Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/terrain/cli_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func TestCLISmoke_PRCommand(t *testing.T) {
root := fixtureRoot(t)

out, err := captureRun(func() error {
return runPR(root, "HEAD~1", true, "")
return runPR(root, "HEAD~1", true, "", severityGateNone)
})
if err != nil {
t.Errorf("pr failed: %v", err)
Expand Down
56 changes: 52 additions & 4 deletions cmd/terrain/cmd_impact.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/pmclSF/terrain/internal/changescope"
"github.com/pmclSF/terrain/internal/depgraph"
"github.com/pmclSF/terrain/internal/engine"
"github.com/pmclSF/terrain/internal/explain"
"github.com/pmclSF/terrain/internal/impact"
"github.com/pmclSF/terrain/internal/metrics"
"github.com/pmclSF/terrain/internal/reporting"
Expand Down Expand Up @@ -40,7 +41,7 @@ func runImpactPipeline(root, baseRef string, opts engine.PipelineOptions) (*impa
return impactResult, result, nil
}

func runImpact(root, baseRef string, jsonOutput bool, show, ownerFilter string) error {
func runImpact(root, baseRef string, jsonOutput bool, show, ownerFilter string, explainSelection bool) error {
impactResult, _, err := runImpactPipeline(root, baseRef, defaultPipelineOptionsWithProgress(jsonOutput))
if err != nil {
return err
Expand All @@ -51,6 +52,27 @@ func runImpact(root, baseRef string, jsonOutput bool, show, ownerFilter string)
impactResult = impact.FilterByOwner(impactResult, ownerFilter)
}

// `--explain-selection` defends the pitch claim
// "see which tests matter for a PR — and why" (Track 3.2). Surfaces
// the structured reason chains that internal/explain produces and
// renders them via the existing RenderSelectionExplanation. Passes
// `verbose=true` so per-test evidence (selection reasons, code unit
// matches, confidence) is included; that's the whole point of the
// flag.
if explainSelection {
sel, err := explain.ExplainSelection(impactResult)
if err != nil {
return fmt.Errorf("could not build selection explanation: %w", err)
}
if jsonOutput {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(sel)
}
reporting.RenderSelectionExplanation(os.Stdout, sel, true)
return nil
}

if jsonOutput {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
Expand Down Expand Up @@ -129,18 +151,44 @@ func applyImpactPolicy(impactResult *impact.ImpactResult, result *engine.Pipelin
}
}

func runPR(root, baseRef string, jsonOutput bool, format string) error {
func runPR(root, baseRef string, jsonOutput bool, format string, gate severityGate) error {
impactResult, result, err := runImpactPipeline(root, baseRef, defaultPipelineOptionsWithProgress(jsonOutput))
if err != nil {
return err
}

pr := changescope.AnalyzePRFromImpact(impactResult, result.Snapshot)

// Compute the gate decision BEFORE rendering so the report renders
// for every output format (json, markdown, comment, annotation,
// default text), AND the gate error returns through the same code
// path. Mirrors the pattern used by `runAnalyze` after the JSON-
// stdout-purity bug fix in PR #134 — the renderer always completes
// before the exit decision is made.
severities := make([]string, 0, len(pr.NewFindings))
for _, f := range pr.NewFindings {
severities = append(severities, f.Severity)
}
if pr.AI != nil {
for _, s := range pr.AI.BlockingSignals {
severities = append(severities, s.Severity)
}
}
gateBlocked, gateSummary := severityGateBlocked(gate, prSeverityBreakdown(severities))
gateErr := func() error {
if gateBlocked {
return fmt.Errorf("%w: --fail-on=%s matched %s", errSeverityGateBlocked, gate, gateSummary)
}
return nil
}

if jsonOutput {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(pr)
if err := enc.Encode(pr); err != nil {
return err
}
return gateErr()
}

switch format {
Expand All @@ -153,5 +201,5 @@ func runPR(root, baseRef string, jsonOutput bool, format string) error {
default:
changescope.RenderChangeScopedReport(os.Stdout, pr)
}
return nil
return gateErr()
}
10 changes: 8 additions & 2 deletions cmd/terrain/cmd_report_namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,10 @@ func runReportImpactCLI(args []string) error {
jsonOut := fs.Bool("json", false, "output JSON impact result")
show := fs.String("show", "", "drill-down view: units, gaps, tests, owners, graph, selected")
owner := fs.String("owner", "", "filter results by owner")
explainSelection := fs.Bool("explain-selection", false, "render the selection explanation: which tests matter for this PR — and why")
_ = fs.Parse(args)
mountPositionalAsRoot("report impact", fs.Args(), root)
return runImpact(*root, *baseRef, *jsonOut, *show, *owner)
return runImpact(*root, *baseRef, *jsonOut, *show, *owner, *explainSelection)
}

func runReportPRCLI(args []string) error {
Expand All @@ -211,9 +212,14 @@ func runReportPRCLI(args []string) error {
baseRef := fs.String("base", "", "git base ref for diff (default: HEAD~1)")
jsonOut := fs.Bool("json", false, "output JSON PR analysis")
format := fs.String("format", "", "output format: markdown, comment, annotation")
failOn := fs.String("fail-on", "", "exit non-zero when a finding at or above this severity is present (critical|high|medium)")
_ = fs.Parse(args)
mountPositionalAsRoot("report pr", fs.Args(), root)
return runPR(*root, *baseRef, *jsonOut, *format)
gate, err := parseSeverityGate(*failOn)
if err != nil {
return err
}
return runPR(*root, *baseRef, *jsonOut, *format, gate)
}

func runReportPostureCLI(args []string) error {
Expand Down
38 changes: 35 additions & 3 deletions cmd/terrain/cmd_severity_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,44 @@ import (
"github.com/pmclSF/terrain/internal/analyze"
)

// errSeverityGateBlocked is the sentinel returned by runAnalyze when
// `--fail-on` matches at least one finding. main.go uses errors.Is to
// distinguish this from analysis errors and exit with
// errSeverityGateBlocked is the sentinel returned by runAnalyze and
// runPR when `--fail-on` matches at least one finding. main.go uses
// errors.Is to distinguish this from analysis errors and exit with
// `exitSeverityGateBlock` (6) rather than the generic 1.
var errSeverityGateBlocked = errors.New("severity gate blocked")

// prSeverityBreakdown converts a PR's change-scoped findings + AI
// blocking signals into the same SignalBreakdown shape that
// `analyze.SignalSummary` uses, so `severityGateBlocked` works
// uniformly across `terrain analyze --fail-on` and
// `terrain report pr --fail-on`. Track 3.1 — defends the pitch's
// "gate changes based on that system as a whole" claim by sharing
// the gate decision logic, not duplicating it.
//
// Counted by case-insensitive severity match. Unknown severities
// are dropped — the renderer is the source of truth for severity
// vocabulary.
func prSeverityBreakdown(severities []string) analyze.SignalBreakdown {
var b analyze.SignalBreakdown
for _, sev := range severities {
switch strings.ToLower(strings.TrimSpace(sev)) {
case "critical":
b.Critical++
b.Total++
case "high":
b.High++
b.Total++
case "medium":
b.Medium++
b.Total++
case "low":
b.Low++
b.Total++
}
}
return b
}

// severityGate represents the threshold for `--fail-on`. Findings at
// or above this severity cause the analyze command to exit with
// `exitSeverityGateBlock`. Empty string means "no gate" (the default).
Expand Down
50 changes: 50 additions & 0 deletions cmd/terrain/cmd_severity_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,56 @@ func TestRunAnalyze_JSONStdoutPurity(t *testing.T) {
}
}

// TestPRSeverityBreakdown verifies the helper that converts a PR's
// findings + AI blocking signals into a SignalBreakdown for the gate.
// Track 3.1 — the gate decision must apply uniformly across analyze
// + pr, sharing one helper.
func TestPRSeverityBreakdown(t *testing.T) {
t.Parallel()
cases := []struct {
name string
severities []string
want analyze.SignalBreakdown
}{
{
name: "empty",
severities: nil,
want: analyze.SignalBreakdown{},
},
{
name: "mixed bag",
severities: []string{"critical", "high", "high", "medium", "low"},
want: analyze.SignalBreakdown{
Total: 5, Critical: 1, High: 2, Medium: 1, Low: 1,
},
},
{
name: "case insensitive + whitespace",
severities: []string{" HIGH ", "Critical"},
want: analyze.SignalBreakdown{Total: 2, Critical: 1, High: 1},
},
{
name: "unknown severities dropped silently",
severities: []string{"high", "weird-tier", "info", ""},
want: analyze.SignalBreakdown{Total: 1, High: 1},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := prSeverityBreakdown(tc.severities)
if got.Total != tc.want.Total ||
got.Critical != tc.want.Critical ||
got.High != tc.want.High ||
got.Medium != tc.want.Medium ||
got.Low != tc.want.Low {
t.Errorf("prSeverityBreakdown(%v) = %+v, want %+v",
tc.severities, got, tc.want)
}
})
}
}

// TestRunAnalyze_GatePassesWhenSeverityAbsent verifies the inverse:
// `--fail-on critical` against a fixture whose worst severity is
// medium returns nil (no gate block).
Expand Down
15 changes: 13 additions & 2 deletions cmd/terrain/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,10 @@ func main() {
jsonFlag := impactCmd.Bool("json", false, "output JSON impact result")
showFlag := impactCmd.String("show", "", "drill-down view: units, gaps, tests, owners, graph, selected")
ownerFlag := impactCmd.String("owner", "", "filter results by owner")
explainFlag := impactCmd.Bool("explain-selection", false, "render the selection explanation: which tests matter for this PR — and why")
_ = impactCmd.Parse(os.Args[2:])
mountPositionalAsRoot("impact", impactCmd.Args(), rootFlag)
if err := runImpact(*rootFlag, *baseRef, *jsonFlag, *showFlag, *ownerFlag); err != nil {
if err := runImpact(*rootFlag, *baseRef, *jsonFlag, *showFlag, *ownerFlag, *explainFlag); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
Expand Down Expand Up @@ -463,9 +464,19 @@ func main() {
baseRef := prCmd.String("base", "", "git base ref for diff (default: HEAD~1)")
jsonFlag := prCmd.Bool("json", false, "output JSON PR analysis")
formatFlag := prCmd.String("format", "", "output format: markdown, comment, annotation")
failOnFlag := prCmd.String("fail-on", "", "exit "+fmt.Sprintf("%d", exitSeverityGateBlock)+" when at least one finding (NewFindings + AI BlockingSignals) is at or above this severity (critical|high|medium)")
_ = prCmd.Parse(os.Args[2:])
mountPositionalAsRoot("pr", prCmd.Args(), rootFlag)
if err := runPR(*rootFlag, *baseRef, *jsonFlag, *formatFlag); err != nil {
gate, gateErr := parseSeverityGate(*failOnFlag)
if gateErr != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", gateErr)
os.Exit(exitUsageError)
}
if err := runPR(*rootFlag, *baseRef, *jsonFlag, *formatFlag, gate); err != nil {
if errors.Is(err, errSeverityGateBlocked) {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(exitSeverityGateBlock)
}
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
Expand Down