diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index d76363c3a..420146dc7 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -1557,7 +1557,7 @@ export async function recordGateScoreSignals( const occurredAt = nowIso(); const writes: Promise[] = []; - const slopMode = gateMode(effective.slopGateMode); + const slopMode = gateMode(effective.slopGateMode ?? "advisory"); const slopRisk = normalizeScore(effective.slopRisk); if (slopMode === "block" && slopRisk !== null) { const slopMin = normalizeScore(effective.slopGateMinScore) ?? DEFAULT_SLOP_BLOCK_THRESHOLD; @@ -1577,7 +1577,7 @@ export async function recordGateScoreSignals( ); } - const qualityMode = gateMode(effective.qualityGateMode); + const qualityMode = gateMode(effective.qualityGateMode ?? "advisory"); const readinessScore = normalizeScore(effective.readinessScore); const qualityMin = normalizeScore(effective.qualityGateMinScore); if (qualityMode !== "off" && readinessScore !== null && qualityMin !== null) { @@ -1601,7 +1601,7 @@ export async function recordGateScoreSignals( } function buildQualityGateWarning(policy: GateCheckPolicy): AdvisoryFinding | null { - if (gateMode(policy.qualityGateMode) === "off") return null; + if (gateMode(policy.qualityGateMode ?? "advisory") === "off") return null; const score = normalizeScore(policy.readinessScore); const minScore = normalizeScore(policy.qualityGateMinScore); if (score === null || minScore === null || score >= minScore) return null; @@ -1635,8 +1635,8 @@ function buildSlopGateBlocker(policy: GateCheckPolicy): AdvisoryFinding | null { } // #9167: fail CLOSED on a value that isn't one of the three real modes, matching the rest of this -// codebase's fail-closed defaults -- every legitimate caller already supplies its own `?? "advisory"` -// default before reaching here (see every `gateMode(policy.xGateMode ?? "advisory")` call site above), so +// codebase's fail-closed defaults -- every call site now supplies its own `?? "advisory"` default +// before reaching here (see every `gateMode(policy.xGateMode ?? "advisory")` call site above), so // this branch is only ever reached for a truly malformed value (e.g. a caller that bypassed // GateRuleMode's compile-time union via an untyped/JSON-decoded config). Previously coerced to // "advisory" -- a fail-OPEN default in a codebase whose other defaults are carefully fail-closed. This is diff --git a/test/unit/configured-gate-blocker-signals.test.ts b/test/unit/configured-gate-blocker-signals.test.ts index b8f6a5899..bb7e010c7 100644 --- a/test/unit/configured-gate-blocker-signals.test.ts +++ b/test/unit/configured-gate-blocker-signals.test.ts @@ -331,6 +331,16 @@ describe("recordGateScoreSignals (#8223)", () => { expect((await createSignalStore(env).queryRuleHistory("slop_gate_score", 0)).fired).toEqual([]); }); + it("records NOTHING for slop when slopGateMode is unset — mirrors buildSlopGateBlocker's own advisory default (#10015)", async () => { + // Regression: gateMode(effective.slopGateMode) used to bare-call gateMode without the `?? "advisory"` + // default, so an unset slopGateMode fail-closed to "block" here while buildSlopGateBlocker (the pure + // evaluator this capture is documented to mirror) resolved the same unset value to "advisory" and never + // evaluated. That mismatch wrote a "mode block" signal for a threshold comparison the gate never made. + const env = createTestEnv(); + await recordGateScoreSignals(env, { slopRisk: 72, slopGateMinScore: 60 }, "owner/repo", 7); + expect((await createSignalStore(env).queryRuleHistory("slop_gate_score", 0)).fired).toEqual([]); + }); + it("fires quality_gate_score in advisory mode too — pass AND fail evaluations both leave corpus evidence", async () => { const env = createTestEnv(); await recordGateScoreSignals(env, { qualityGateMode: "advisory", readinessScore: 80, qualityGateMinScore: 70 }, "owner/repo", 7); @@ -349,6 +359,20 @@ describe("recordGateScoreSignals (#8223)", () => { expect((await createSignalStore(env).queryRuleHistory("quality_gate_score", 0)).fired).toEqual([]); }); + it("still fires quality_gate_score when qualityGateMode is unset — the #10015 default fix doesn't change this path's outcome", async () => { + // qualityMode resolves to "advisory" now instead of the old bare-call "block", but the firing condition + // is only `!== "off"` either way, so an unset qualityGateMode still records — pinning that the fix is + // scoped to the mismatch (slop's block-only firing condition) and doesn't regress quality's capture. + const env = createTestEnv(); + await recordGateScoreSignals(env, { readinessScore: 40, qualityGateMinScore: 70 }, "owner/repo", 7); + const history = await createSignalStore(env).queryRuleHistory("quality_gate_score", 0); + expect(history.fired).toHaveLength(1); + expect(history.fired[0]).toMatchObject({ + outcome: "below_threshold", + metadata: { rawSignal: "public readiness score 40/100 vs threshold 70/100 (mode advisory)" }, + }); + }); + it("degrades silently when the SignalStore write rejects — the call resolves normally", async () => { vi.spyOn(signalTrackingWire, "createSignalStore").mockReturnValue({ recordRuleFired: async () => {