diff --git a/src/services/maintainer-recap-gate-outcomes.ts b/src/services/maintainer-recap-gate-outcomes.ts new file mode 100644 index 0000000000..1064fb6b29 --- /dev/null +++ b/src/services/maintainer-recap-gate-outcomes.ts @@ -0,0 +1,85 @@ +// Maintainer-recap GATE-OUTCOMES section (#2242, content slice of the #1963 recap digest). +// +// Pure section builder over a RecapReport projection: summarize the gate's window — how many PRs the +// gate blocked, how many maintainers OVERRODE, and the blocked-then-merged FALSE-POSITIVE count + rate — +// straight from the same GatePrecisionReport totals that services/gate-precision.ts aggregates (the source +// src/review/ops-wire.ts reads). No delivery, no scheduling, no new queries. +// +// The false-positive rate is NULLED below MIN_SAMPLE exactly as gate-precision.ts:103 — a 1-of-1 "false +// positive" is noise, not a precision signal. Own file (mirroring maintainer-recap-calibration.ts) so it +// stays decoupled from the foundation builder and sibling sections (zero shared-file conflict surface). +import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction"; + +// Mirror gate-precision.ts:22 — the rate is noise below this many blocks, so it reports as null (n/a). +const MIN_SAMPLE = 5; + +/** Mirror gate-precision.ts:41 round() — three decimal places. */ +function round(value: number): number { + return Math.round(value * 1000) / 1000; +} + +/** Projection of RecapReport used by the gate-outcomes section (window + gate totals only). */ +export type GateOutcomesRecapSource = { + windowDays: number; + totals: { + /** Total gate blocks over the window (the rate denominator). */ + blocked: number; + /** Blocks that later MERGED anyway — a gate FALSE POSITIVE. */ + gateFalsePositives: number; + /** Blocks a maintainer explicitly OVERRODE. */ + gateOverrides: number; + }; +}; + +/** One titled digest section: structured fields for consumers + ready-to-emit lines for the formatter. */ +export type GateOutcomesRecapSection = { + title: string; + blocked: number; + overridden: number; + falsePositives: number; + /** blockedThenMerged / blocked, 3 dp — NULL below MIN_SAMPLE (gate-precision.ts:103). */ + falsePositiveRate: number | null; + lines: string[]; +}; + +/** Public-safe scrub for free text pulled into the section (defense in depth — counts are the only inputs + * today). Mirrors maintainer-recap-calibration.ts. */ +function sanitizeRecapText(value: string): string { + return value.replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "").slice(0, 240); +} + +/** + * Pure gate-outcomes section over a RecapReport projection. + * + * - `falsePositiveRate` = gateFalsePositives / blocked, rounded to 3 dp — but **null below MIN_SAMPLE** + * (and therefore also when nothing was blocked), exactly as gate-precision.ts nulls a low-sample rate. + * - The rate line reads "n/a" on the null arm so the digest still carries a gate-outcomes section. + */ +export function buildGateOutcomesRecapSection(report: GateOutcomesRecapSource): GateOutcomesRecapSection { + const { blocked, gateFalsePositives, gateOverrides } = report.totals; + // Null the rate below MIN_SAMPLE (gate-precision.ts:103) — a 1-of-1 "false positive" is noise. This also + // covers the divide-by-zero arm (blocked === 0 < MIN_SAMPLE), so the ratio is never evaluated at 0. + const falsePositiveRate = blocked >= MIN_SAMPLE ? round(gateFalsePositives / blocked) : null; + + const rateLine = + falsePositiveRate === null + ? `False-positive rate: n/a (fewer than ${MIN_SAMPLE} blocks in the last ${report.windowDays} day(s))` + : `False-positive rate: ${Math.round(falsePositiveRate * 100)}% (${gateFalsePositives} of ${blocked} blocks merged anyway)`; + + const title = "Gate outcomes"; + const lines = [ + `Blocked: ${blocked}`, + `Maintainer overrides: ${gateOverrides}`, + `False positives (blocked then merged): ${gateFalsePositives}`, + rateLine, + ].map(sanitizeRecapText); + + return { + title, + blocked, + overridden: gateOverrides, + falsePositives: gateFalsePositives, + falsePositiveRate, + lines, + }; +} diff --git a/test/unit/maintainer-recap-gate-outcomes.test.ts b/test/unit/maintainer-recap-gate-outcomes.test.ts new file mode 100644 index 0000000000..06183e5e38 --- /dev/null +++ b/test/unit/maintainer-recap-gate-outcomes.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + buildGateOutcomesRecapSection, + type GateOutcomesRecapSource, +} from "../../src/services/maintainer-recap-gate-outcomes"; + +const WINDOW = 7; + +function report(totals: GateOutcomesRecapSource["totals"], windowDays = WINDOW): GateOutcomesRecapSource { + return { windowDays, totals }; +} + +describe("buildGateOutcomesRecapSection (#2242)", () => { + it("emits counts + a numeric rate when blocks clear MIN_SAMPLE (enough-samples arm)", () => { + // 10 blocks ≥ MIN_SAMPLE(5), 3 merged anyway ⇒ rate 0.3. + const section = buildGateOutcomesRecapSection(report({ blocked: 10, gateFalsePositives: 3, gateOverrides: 2 })); + expect(section.title).toBe("Gate outcomes"); + expect(section.blocked).toBe(10); + expect(section.overridden).toBe(2); + expect(section.falsePositives).toBe(3); + expect(section.falsePositiveRate).toBe(0.3); + expect(section.lines).toEqual([ + "Blocked: 10", + "Maintainer overrides: 2", + "False positives (blocked then merged): 3", + "False-positive rate: 30% (3 of 10 blocks merged anyway)", + ]); + }); + + it("nulls the rate below MIN_SAMPLE (too-few-samples arm) — a 1-of-few FP is noise, not a signal", () => { + // 4 blocks < MIN_SAMPLE(5) ⇒ rate null even though a block merged anyway. + const section = buildGateOutcomesRecapSection(report({ blocked: 4, gateFalsePositives: 1, gateOverrides: 1 })); + expect(section.falsePositiveRate).toBeNull(); + expect(section.falsePositives).toBe(1); + expect(section.lines[3]).toBe("False-positive rate: n/a (fewer than 5 blocks in the last 7 day(s))"); + }); + + it("treats exactly MIN_SAMPLE blocks as enough (boundary — blocked === 5)", () => { + const section = buildGateOutcomesRecapSection(report({ blocked: 5, gateFalsePositives: 1, gateOverrides: 0 })); + expect(section.falsePositiveRate).toBe(0.2); // 1/5, ≥ MIN_SAMPLE ⇒ numeric + expect(section.lines[3]).toBe("False-positive rate: 20% (1 of 5 blocks merged anyway)"); + }); + + it("nulls the rate on an empty report without dividing by zero (blocked === 0 arm)", () => { + const section = buildGateOutcomesRecapSection(report({ blocked: 0, gateFalsePositives: 0, gateOverrides: 0 })); + expect(section.falsePositiveRate).toBeNull(); + expect(Number.isNaN(section.falsePositiveRate as number)).toBe(false); + expect(section.lines).toEqual([ + "Blocked: 0", + "Maintainer overrides: 0", + "False positives (blocked then merged): 0", + "False-positive rate: n/a (fewer than 5 blocks in the last 7 day(s))", + ]); + }); + + it("rounds the rate to three decimals like gate-precision.ts (percent via Math.round)", () => { + // 1/6 = 0.16666… ⇒ round(*1000)/1000 = 0.167; percent line ⇒ Math.round(16.7) = 17%. + const section = buildGateOutcomesRecapSection(report({ blocked: 6, gateFalsePositives: 1, gateOverrides: 0 })); + expect(section.falsePositiveRate).toBe(0.167); + expect(section.lines[3]).toBe("False-positive rate: 17% (1 of 6 blocks merged anyway)"); + }); + + it("scrubs a local-path leak from every emitted line (defense-in-depth — lines are count-derived today)", () => { + const section = buildGateOutcomesRecapSection(report({ blocked: 7, gateFalsePositives: 0, gateOverrides: 0 })); + for (const line of section.lines) { + expect(line).not.toMatch(/\/Users\//); + expect(line).not.toMatch(/\/tmp\//); + } + }); +});