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
26 changes: 26 additions & 0 deletions docs/rules/engine/detector-missing-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# TER-ENGINE-003 — Detector Missing Input

> Auto-generated stub. Edit anything below the marker; the generator preserves it.

**Type:** `detectorMissingInput`
**Domain:** quality
**Default severity:** low
**Status:** stable

## Summary

A registered detector requires inputs (runtime artifacts, baseline snapshot, or eval-framework results) that the current snapshot doesn't carry. The detector was skipped; the rest of the pipeline ran normally.

## Remediation

The marker explanation lists the specific flag(s) to pass to `terrain analyze` to provide the missing inputs. If you don't need this detector's signals, leave the inputs absent — the marker is informational.

## Evidence sources

- `static`

## Confidence range

Detector confidence is bracketed at [1.00, 1.00] (heuristic in 0.2; calibration in 0.3).

<!-- docs-gen: end stub. Hand-authored content below this line is preserved across regenerations. -->
17 changes: 17 additions & 0 deletions docs/signals/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,23 @@
],
"ruleId": "TER-ENGINE-001",
"ruleUri": "docs/rules/engine/detector-panic.md"
},
{
"type": "detectorMissingInput",
"constName": "SignalDetectorMissingInput",
"domain": "quality",
"status": "stable",
"title": "Detector Missing Input",
"description": "A registered detector requires inputs (runtime artifacts, baseline snapshot, or eval-framework results) that the current snapshot doesn't carry. The detector was skipped; the rest of the pipeline ran normally.",
"remediation": "The marker explanation lists the specific flag(s) to pass to `terrain analyze` to provide the missing inputs. If you don't need this detector's signals, leave the inputs absent — the marker is informational.",
"defaultSeverity": "low",
"confidenceMin": 1,
"confidenceMax": 1,
"evidenceSources": [
"static"
],
"ruleId": "TER-ENGINE-003",
"ruleUri": "docs/rules/engine/detector-missing-input.md"
}
]
}
6 changes: 6 additions & 0 deletions internal/models/signal_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ var SignalCatalog = map[SignalType]SignalCatalogEntry{
// the entire snapshot the moment any detector panicked, defeating
// the panic-recovery shipped in 0.2.
"detectorPanic": {Source: SignalSourceStatic},
// detectorMissingInput is emitted by safeDetectChecked when a
// detector's RequiresRuntime / RequiresBaseline /
// RequiresEvalArtifact metadata is set but the snapshot lacks
// the corresponding input. Track 9.3 — surfaces input gaps as
// a single per-detector marker instead of silent zero-output.
"detectorMissingInput": {Source: SignalSourceStatic},
}

// KnownSignalTypes is the canonical signal vocabulary accepted by snapshot
Expand Down
154 changes: 148 additions & 6 deletions internal/signals/detector_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"runtime/debug"
"sort"
"strings"
"sync"

"github.com/pmclSF/terrain/internal/depgraph"
Expand Down Expand Up @@ -39,6 +40,96 @@ func safeDetect(reg DetectorRegistration, fn func() []models.Signal) (out []mode
return fn()
}

// signalTypeMissingInputDiagnostic is the marker emitted by the
// registry when a detector's RequiresRuntime / RequiresBaseline /
// RequiresEvalArtifact flag is set but the snapshot doesn't carry
// the corresponding input. Track 9.3 — adopters running `terrain
// analyze` without coverage / baseline / eval artifacts get a
// single visible diagnostic per affected detector instead of
// silent zero-output.
const signalTypeMissingInputDiagnostic = SignalDetectorMissingInput

// missingInputs returns a list of human-readable input-name strings
// that the detector's metadata says it needs but the snapshot
// doesn't provide. Empty list means the detector can run; non-empty
// means the registry should emit a missingInputDiagnostic and skip
// invocation. Each input name corresponds to a CLI flag the user
// would set to provide the input.
func missingInputs(meta DetectorMeta, snap *models.TestSuiteSnapshot) []string {
if snap == nil {
return nil
}
var missing []string
if meta.RequiresRuntime && !snapshotHasRuntime(snap) {
missing = append(missing, "runtime artifacts (--runtime path/to/junit.xml or jest.json)")
}
if meta.RequiresBaseline && snap.Baseline == nil {
missing = append(missing, "baseline snapshot (--baseline path/to/old-snapshot.json)")
}
if meta.RequiresEvalArtifact && len(snap.EvalRuns) == 0 {
missing = append(missing, "eval-framework artifact (--promptfoo-results / --deepeval-results / --ragas-results)")
}
return missing
}

// snapshotHasRuntime reports whether the snapshot carries any
// runtime test result data. We look at the test-file inventory
// rather than walking every signal — the runtime stats live on the
// TestFile, not on signals.
func snapshotHasRuntime(snap *models.TestSuiteSnapshot) bool {
for i := range snap.TestFiles {
if snap.TestFiles[i].RuntimeStats != nil {
return true
}
}
return false
}

// missingInputDiagnostic builds the marker signal emitted when one
// or more required inputs are absent. The explanation lists every
// missing input so adopters can fix them all in one re-run rather
// than playing whack-a-mole.
func missingInputDiagnostic(meta DetectorMeta, missing []string) models.Signal {
return models.Signal{
Type: signalTypeMissingInputDiagnostic,
Category: models.CategoryQuality,
Severity: models.SeverityLow,
Confidence: 1.0,
Explanation: fmt.Sprintf(
"detector %q requires inputs the current snapshot doesn't carry: %s",
meta.ID, joinInputNames(missing)),
SuggestedAction: "Re-run `terrain analyze` with the listed flags to enable this detector. If you don't need its signals, leave the inputs absent — this diagnostic surfaces the gap without blocking the rest of the pipeline.",
}
}

// safeDetectChecked is the registry's canonical detector-invocation
// path. It runs missing-input checks first (Track 9.3 — emits a
// diagnostic and skips invocation when required inputs are absent)
// and otherwise delegates to safeDetect for the actual run. All
// call sites in Run / RunWithGraph route through here.
func safeDetectChecked(reg DetectorRegistration, snap *models.TestSuiteSnapshot, fn func() []models.Signal) []models.Signal {
if missing := missingInputs(reg.Meta, snap); len(missing) > 0 {
return []models.Signal{missingInputDiagnostic(reg.Meta, missing)}
}
return safeDetect(reg, fn)
}

func joinInputNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " and " + names[1]
default:
// Oxford comma, plain English: "a, b, c, and d".
// Join all but the last with ", " then append ", and <last>".
head := names[:len(names)-1]
return strings.Join(head, ", ") + ", and " + names[len(names)-1]
}
}

// Domain classifies a detector's area of concern.
type Domain string

Expand Down Expand Up @@ -96,6 +187,57 @@ type DetectorMeta struct {
// RequiresGraph indicates this detector needs the dependency graph.
// Graph detectors run in Phase 2 (after flat detectors, before signal-dependent).
RequiresGraph bool

// --- Track 9.1 capability metadata ---
//
// The fields below describe what a detector consumes beyond the
// in-memory snapshot. They're descriptive (so docs / `terrain
// doctor` can surface "this detector needs runtime data") AND
// load-bearing (Track 9.3 — when a required input is missing
// the registry emits a single per-detector missingInputDiagnostic
// instead of silently running a detector that can't fire).
//
// All zero values mean "don't require this input", which keeps
// the existing detector roster behaving exactly as before. New
// detectors that genuinely need runtime / baseline / eval data
// should set the relevant flag so the diagnostic surfaces when
// inputs are absent.

// RequiresRuntime indicates the detector reads RuntimeStats from
// the snapshot (populated by JUnit XML / Jest JSON / Go test
// JSON ingestion). Without runtime artifacts the snapshot's
// runtime fields are empty and the detector cannot fire.
RequiresRuntime bool

// RequiresBaseline indicates the detector compares the current
// snapshot against a baseline snapshot (passed via
// `terrain analyze --baseline`). Without it, the regression
// detectors (aiCostRegression, aiHallucinationRate,
// aiRetrievalRegression) have no point of comparison.
RequiresBaseline bool

// RequiresEvalArtifact indicates the detector reads EvalRuns
// from the snapshot (populated by Promptfoo / DeepEval / Ragas
// adapter ingestion). Without an artifact path passed via the
// `--{promptfoo,deepeval,ragas}-results` flags, the snapshot's
// EvalRuns is empty and these detectors can't fire.
RequiresEvalArtifact bool

// ContextAware reports whether the detector honors ctx.Err() in
// its inner loops. Detectors that don't are still safe — they
// run inside safeDetectWithBudget and get abandoned at the
// budget cap — but ctx-aware detectors can react faster to
// pipeline cancellation. Surfaced in `terrain doctor` so
// reviewers can see the cancellation posture per-detector.
ContextAware bool

// Experimental marks the detector as not-yet-stable. Distinct
// from the manifest's Status field on individual signals: a
// stable signal type can still have an experimental detector
// (the type is locked, the detector implementation is not).
// Experimental detectors are excluded from the recommended
// `--fail-on critical` gate per the trust-tier framing.
Experimental bool
}

// DetectorRegistration pairs a Detector with its metadata.
Expand Down Expand Up @@ -192,7 +334,7 @@ func (r *DetectorRegistry) Run(snap *models.TestSuiteSnapshot) {
wg.Add(1)
go func(idx int, reg DetectorRegistration) {
defer wg.Done()
found := safeDetect(reg, func() []models.Signal { return reg.Detector.Detect(snap) })
found := safeDetectChecked(reg, snap, func() []models.Signal { return reg.Detector.Detect(snap) })
mu.Lock()
results = append(results, result{idx: idx, signals: found})
mu.Unlock()
Expand All @@ -209,7 +351,7 @@ func (r *DetectorRegistry) Run(snap *models.TestSuiteSnapshot) {

// Dependent detectors run after independent outputs are available.
for _, reg := range dependents {
found := safeDetect(reg, func() []models.Signal { return reg.Detector.Detect(snap) })
found := safeDetectChecked(reg, snap, func() []models.Signal { return reg.Detector.Detect(snap) })
snap.Signals = append(snap.Signals, found...)
}
}
Expand Down Expand Up @@ -253,7 +395,7 @@ func (r *DetectorRegistry) RunWithGraph(snap *models.TestSuiteSnapshot, g *depgr
wg.Add(1)
go func(idx int, reg DetectorRegistration) {
defer wg.Done()
found := safeDetect(reg, func() []models.Signal { return reg.Detector.Detect(snap) })
found := safeDetectChecked(reg, snap, func() []models.Signal { return reg.Detector.Detect(snap) })
mu.Lock()
results = append(results, result{idx: idx, signals: found})
mu.Unlock()
Expand Down Expand Up @@ -296,7 +438,7 @@ func (r *DetectorRegistry) RunWithGraph(snap *models.TestSuiteSnapshot, g *depgr
wg2.Add(1)
go func(idx int, reg DetectorRegistration, gd GraphDetector) {
defer wg2.Done()
found := safeDetect(reg, func() []models.Signal { return gd.DetectWithGraph(snap, g) })
found := safeDetectChecked(reg, snap, func() []models.Signal { return gd.DetectWithGraph(snap, g) })
mu.Lock()
graphResults = append(graphResults, result{idx: idx, signals: found})
mu.Unlock()
Expand All @@ -314,7 +456,7 @@ func (r *DetectorRegistry) RunWithGraph(snap *models.TestSuiteSnapshot, g *depgr

// Phase 3: Signal-dependent detectors (sequential).
for _, reg := range dependents {
found := safeDetect(reg, func() []models.Signal { return reg.Detector.Detect(snap) })
found := safeDetectChecked(reg, snap, func() []models.Signal { return reg.Detector.Detect(snap) })
snap.Signals = append(snap.Signals, found...)
}
}
Expand All @@ -323,7 +465,7 @@ func (r *DetectorRegistry) RunWithGraph(snap *models.TestSuiteSnapshot, g *depgr
func (r *DetectorRegistry) RunDomain(snap *models.TestSuiteSnapshot, domain Domain) {
for _, reg := range r.registrations {
if reg.Meta.Domain == domain {
found := safeDetect(reg, func() []models.Signal { return reg.Detector.Detect(snap) })
found := safeDetectChecked(reg, snap, func() []models.Signal { return reg.Detector.Detect(snap) })
snap.Signals = append(snap.Signals, found...)
}
}
Expand Down
16 changes: 16 additions & 0 deletions internal/signals/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,22 @@ var allSignalManifest = []ManifestEntry{
EvidenceSources: []string{"static"},
RuleID: "TER-ENGINE-001", RuleURI: "docs/rules/engine/detector-panic.md",
},
// Track 9.3: emitted by safeDetectChecked when a detector's
// declared input requirements (RequiresRuntime / RequiresBaseline
// / RequiresEvalArtifact) aren't satisfied by the current
// snapshot. Surfaces the gap so adopters know which flag to add
// rather than seeing silent zero-output from the affected detector.
{
Type: SignalDetectorMissingInput, ConstName: "SignalDetectorMissingInput",
Domain: models.CategoryQuality, Status: StatusStable,
Title: "Detector Missing Input",
Description: "A registered detector requires inputs (runtime artifacts, baseline snapshot, or eval-framework results) that the current snapshot doesn't carry. The detector was skipped; the rest of the pipeline ran normally.",
Remediation: "The marker explanation lists the specific flag(s) to pass to `terrain analyze` to provide the missing inputs. If you don't need this detector's signals, leave the inputs absent — the marker is informational.",
DefaultSeverity: models.SeverityLow,
ConfidenceMin: 1.0, ConfidenceMax: 1.0,
EvidenceSources: []string{"static"},
RuleID: "TER-ENGINE-003", RuleURI: "docs/rules/engine/detector-missing-input.md",
},
}

// Manifest returns a snapshot copy of the canonical signal manifest, sorted
Expand Down
Loading
Loading