@@ -77,18 +77,44 @@ func runStopHook(ctx context.Context) {
7777 // paths are the ones worth recording: a hook that never fires and a hook that fires
7878 // and finds nothing are indistinguishable in a session, and only one of them is
7979 // broken. See internal/hookstate and DEFECTS_FOUND.md.
80- hookstate .RecordFired (outDir , hookstate .EventStop , stopOutcome (verdict , ok ))
80+ declineKey := ""
81+ if ok {
82+ declineKey = verdict .DeclineKey ()
83+ }
84+ // ShouldReport is asked BEFORE recording, because recording is what makes the next
85+ // identical decline a repeat.
86+ sayDecline := ok && declineKey != "" && hookstate .ShouldReport (outDir , hookstate .EventStop , declineKey )
87+ hookstate .RecordFiredWithReason (outDir , hookstate .EventStop , stopOutcome (verdict , ok ), declineKey )
88+
89+ var context string
90+ switch {
91+ case ok && verdict .Status == check .StatusRegression :
92+ context = "enola graded the architectural change made in this session and found a structural " +
93+ "regression. This was not necessarily intended — review it before considering the task " +
94+ "finished, and either fix it or say why it is deliberate.\n \n " + verdict .Render ()
8195
82- if ! ok || verdict .Status != check .StatusRegression {
96+ case sayDecline :
97+ // The gate could not grade at all, and saying nothing would be indistinguishable
98+ // from grading it clean. `enola check` spends a whole exit code (3) keeping those
99+ // apart so "I refuse to grade this" is never read as "your change is bad"; a hook
100+ // that stays silent collapses the same distinction in the other direction, and
101+ // leaves someone believing the loop is protecting them when it is not.
102+ //
103+ // Said once per distinct reason, not once per session — see hookstate.ShouldReport.
104+ context = "enola could NOT grade the architectural change made in this session: " +
105+ verdict .DeclineReason () + ".\n \n " +
106+ "This is NOT a statement about your change — the comparison itself was untrustworthy, " +
107+ "so no verdict was reached in either direction. Re-pin the baseline to restore grading " +
108+ "(`enola baseline pin`, or the set_baseline tool), and `enola doctor` reports whether " +
109+ "the hooks are grading again."
110+
111+ default :
83112 return
84113 }
85114
86115 var out stopHookOutput
87116 out .HookSpecificOutput .HookEventName = "Stop"
88- out .HookSpecificOutput .AdditionalContext =
89- "enola graded the architectural change made in this session and found a structural " +
90- "regression. This was not necessarily intended — review it before considering the task " +
91- "finished, and either fix it or say why it is deliberate.\n \n " + verdict .Render ()
117+ out .HookSpecificOutput .AdditionalContext = context
92118
93119 encoded , err := json .Marshal (out )
94120 if err != nil {
@@ -189,7 +215,7 @@ func pinBaselineSingleFlight(ctx context.Context, repoDir string) {
189215 defer lock .Release ()
190216
191217 baselineDir := engine .ResolveBaselineDir (outDir , "pinned" )
192- if ! shouldAutoPin (baselineDir , anchor , cfg .Output .Dir ) {
218+ if ! shouldAutoPin (baselineDir , anchor , cfg .Output .Dir , eng . CurrentMeta ( anchor ) ) {
193219 return
194220 }
195221
@@ -225,14 +251,29 @@ func pinBaselineSingleFlight(ctx context.Context, repoDir string) {
225251//
226252// A dirty tree is never treated as current: "dirty" says the content is not identified by
227253// the commit, so two dirty trees at the same commit may differ arbitrarily.
228- func shouldAutoPin (baselineDir , repoDir , outputDir string ) bool {
254+ //
255+ // The third rule is about usefulness rather than freshness: an auto-pinned baseline that
256+ // can no longer be COMPARED to a current snapshot — a different enola version, a changed
257+ // extractor set or ignore globs — is not a baseline at all, and refreshing it costs one
258+ // snapshot where leaving it costs the session's entire grading, silently. Tree movement
259+ // alone missed this: a session starting on a clean unchanged tree after an upgrade
260+ // graded against an unusable baseline and said nothing.
261+ //
262+ // Still only ever applied to baselines this hook created. A deliberate pin stays
263+ // untouched even when unusable — replacing it would discard the "before" of a refactor
264+ // that may span days, which is a worse outcome than a Stop hook that has to explain
265+ // itself. That case is reported instead.
266+ func shouldAutoPin (baselineDir , repoDir , outputDir string , current * facts.SnapshotMeta ) bool {
229267 base , err := bootstrap .LoadSnapshotDir (baselineDir )
230268 if err != nil {
231269 return true // no baseline yet — this is exactly what the hook is for
232270 }
233271 if _ , err := os .Stat (filepath .Join (baselineDir , autoPinMarker )); err != nil {
234272 return false // deliberately pinned; not ours to replace
235273 }
274+ if current != nil && baselineIsUnusable (base .Meta , * current ) {
275+ return true
276+ }
236277 now := engine .GitState (repoDir , outputDir )
237278 if now == nil || base .Meta .Git == nil {
238279 return true // cannot prove it is current, so refresh
@@ -262,6 +303,16 @@ func stopOutcome(v check.Verdict, ok bool) hookstate.Outcome {
262303 }
263304}
264305
306+ // baselineIsUnusable reports whether a BLOCKING comparability warning stands between
307+ // these two snapshots — the same classification `enola check` uses to decline, so the
308+ // hook refreshes exactly what the gate would have refused to grade against. Advisory
309+ // warnings (a stale baseline) are deliberately not included: those still grade, and
310+ // re-pinning on staleness would destroy the multi-day baseline the staleness warning
311+ // exists to permit.
312+ func baselineIsUnusable (base , current facts.SnapshotMeta ) bool {
313+ return len (check .BlockingKinds (diff .CompareMeta (base , current ))) > 0
314+ }
315+
265316// gradeQuietly runs the gate, returning ok=false for every reason a hook should stay
266317// silent rather than report a problem.
267318//
0 commit comments