Skip to content
Merged
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
10 changes: 5 additions & 5 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1557,7 +1557,7 @@ export async function recordGateScoreSignals(
const occurredAt = nowIso();
const writes: Promise<void>[] = [];

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;
Expand All @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions test/unit/configured-gate-blocker-signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down
Loading