diff --git a/src/services/maintainer-recap-config-drift.ts b/src/services/maintainer-recap-config-drift.ts new file mode 100644 index 000000000..bf99747c2 --- /dev/null +++ b/src/services/maintainer-recap-config-drift.ts @@ -0,0 +1,123 @@ +// Maintainer-recap CONFIG-DRIFT section (#8214, epic #8211 track A — content slice of the #1963 recap digest). +// +// Pure section builder over a plain source struct: surface each live knob's CURRENT drift verdict from the +// nightly config-drift sentinel (#8213) — the evaluateKnobDrift report the sentinel computes +// (services/loosening-knobs.ts:143) — so a STANDING drift episode is impossible to miss in the recap. Drift +// alerts are point-in-time; this section is the standing surface. Mirrors buildCalibrationRecapSection +// (maintainer-recap-calibration.ts): a pure section builder over a projection, aggregate numbers + knob ids +// only — the report's per-split BacktestComparisons are deliberately NOT part of the projection, so corpus +// content structurally cannot reach the digest. +// +// Direction semantics mirror KnobDriftReport.direction (loosening-knobs.ts:143): "tighter" = live config is +// likely stale (actionable), "looser" = informational duplicate of the loosening loop's own proposal, +// "shipped" = a drifted override should revert to the registry's shipped value. +import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction"; +import type { ConfigDriftRecapSection } from "../types"; + +/** Projection of one knob's {@link KnobDriftReport} (loosening-knobs.ts:143) used by this section — + * aggregate numbers + the direction verdict only; structurally satisfied by a full KnobDriftReport. */ +export type ConfigDriftReportProjection = { + direction: "looser" | "tighter" | "shipped"; + liveValue: number; + dominatingValue: number; + visibleCases: number; + heldOutCases: number; +}; + +/** One live knob's current sentinel status. `report: null` ⇒ clean (no alternative dominates live). */ +export type ConfigDriftKnobStatus = { + knobId: string; + report: ConfigDriftReportProjection | null; + /** The sentinel's episode fingerprint timestamp (ISO) — when THIS drift episode was first observed. + * Null/absent when clean, or when the sentinel has not stamped one yet. */ + episodeStartedAt?: string | null | undefined; +}; + +/** Plain source struct for the config-drift section — injected by the caller (the sentinel's recap plumbing), + * exactly like CalibrationRecapSource / MaintainerRecapInputs. No I/O here. */ +export type ConfigDriftRecapSource = { + /** The sentinel flag's resolved state. False renders the explicit disabled line — absence of DATA must + * never be mistaken for absence of DRIFT (#8214). */ + sentinelEnabled: boolean; + /** Recap generation instant (ISO) — the "now" each episode's standing time is measured against. */ + generatedAt: string; + knobs: ConfigDriftKnobStatus[]; +}; + +const MS_PER_DAY = 86_400_000; + +/** Public-safe scrub for free text pulled into the section (defense in depth — knob ids are the only + * free-text inputs today). Mirrors maintainer-recap-calibration.ts's sanitizeRecapText. */ +function sanitizeRecapText(value: string): string { + return value.replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "").slice(0, 240); +} + +/** How long a drift episode has stood, measured fingerprint → generatedAt. "episode age unknown" when either + * timestamp is unparseable (absent fingerprint included); clock skew (a fingerprint AFTER generatedAt) + * clamps to 0 so a skewed sentinel never renders a negative age. */ +function describeEpisodeStanding(episodeStartedAt: string | null | undefined, generatedAt: string): string { + const started = Date.parse(episodeStartedAt ?? ""); + const now = Date.parse(generatedAt); + if (!Number.isFinite(started) || !Number.isFinite(now)) return "episode age unknown"; + const days = Math.floor(Math.max(0, now - started) / MS_PER_DAY); + return days < 1 ? "standing <1 day" : `standing ${days} day(s)`; +} + +/** Per-direction plain-English reading, mirroring the consumer guidance on KnobDriftReport.direction: + * "shipped" is checked first (a drifted override should revert), then tighter=actionable / looser=informational. */ +function describeDirection(direction: ConfigDriftReportProjection["direction"]): string { + if (direction === "shipped") return "drifted override should revert to shipped"; + return direction === "tighter" + ? "stale-config warning — a tighter setting dominates live" + : "informational — a looser setting dominates (duplicates the loosening loop's own proposal)"; +} + +/** + * Pure config-drift section over the sentinel's per-knob drift reports (#8214). + * + * - Sentinel flag OFF ⇒ the single explicit "drift sentinel disabled" line — absence of data stays + * distinguishable from absence of drift. + * - Each drifting knob renders direction, live vs dominating value, both corpus sizes, and episode standing + * time; clean knobs collapse into ONE summary line. + * - Note arms: no-knobs-evaluated, drift-present, all-clean. + */ +export function buildConfigDriftRecapSection(source: ConfigDriftRecapSource): ConfigDriftRecapSection { + const title = "Config drift"; + if (!source.sentinelEnabled) { + // The issue-mandated explicit empty state (#8214): the section still renders, and says WHY it is empty. + const note = "drift sentinel disabled — no drift data was collected for this window."; + return { title, sentinelEnabled: false, driftingKnobs: 0, cleanKnobs: 0, note: sanitizeRecapText(note), lines: [sanitizeRecapText(note)] }; + } + + const drifting = source.knobs.flatMap((knob) => (knob.report ? [{ knob, report: knob.report }] : [])); + const cleanCount = source.knobs.length - drifting.length; + + const lines = drifting.map( + ({ knob, report }) => + `${knob.knobId} — ${describeDirection(report.direction)}: live ${report.liveValue} vs dominating ${report.dominatingValue} (${report.visibleCases} visible / ${report.heldOutCases} held-out case(s)); ${describeEpisodeStanding(knob.episodeStartedAt, source.generatedAt)}`, + ); + if (cleanCount > 0) { + // The issue's "clean knobs as one summary line" — never one row per clean knob. + lines.push(`${cleanCount} knob(s) clean — no alternative dominates the live value.`); + } + + let note: string; + if (source.knobs.length === 0) { + // Enabled-but-empty is its own state: the sentinel ran and had nothing to evaluate. + note = "Drift sentinel enabled, but no live knobs were evaluated this window."; + } else if (drifting.length > 0) { + note = `config drift: ${drifting.length} of ${source.knobs.length} live knob(s) have a dominating alternative on the trailing corpus.`; + } else { + note = `Config healthy: all ${source.knobs.length} live knob(s) clean — nothing dominates the live values.`; + } + lines.push(note); + + return { + title, + sentinelEnabled: true, + driftingKnobs: drifting.length, + cleanKnobs: cleanCount, + note: sanitizeRecapText(note), + lines: lines.map(sanitizeRecapText), + }; +} diff --git a/src/services/maintainer-recap.ts b/src/services/maintainer-recap.ts index c7ab21156..e236832d7 100644 --- a/src/services/maintainer-recap.ts +++ b/src/services/maintainer-recap.ts @@ -12,6 +12,7 @@ // calibration ledgers (blocked-then-merged false positives, maintainer overrides, recommendation reversals). import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN, PUBLIC_UNSAFE_PATTERN } from "../signals/redaction"; import { deliverRecapToDiscord, deliverRecapToSlack } from "./notify-discord"; +import { buildConfigDriftRecapSection, type ConfigDriftRecapSource } from "./maintainer-recap-config-drift"; import type { GatePrecisionReport } from "./gate-precision"; import type { OutcomeCalibration } from "./outcome-calibration"; import type { MaintainerRecapCohortCounts, MaintainerRecapRepo, RecapReport } from "../types"; @@ -43,6 +44,9 @@ export type MaintainerRecapInputs = { generatedAt: string; windowDays?: number | null | undefined; repos: MaintainerRecapRepoInput[]; + /** #8214: optional config-drift source (the sentinel's per-knob drift reports, #8213). When present the + * report carries a configDrift section; when omitted the formatter renders the explicit disabled line. */ + configDrift?: ConfigDriftRecapSource | undefined; }; /** #4521: convert one GatePrecisionCohortReport's `overall` bucket into the recap's own cohort-counts shape @@ -138,7 +142,15 @@ export function buildMaintainerRecap(args: MaintainerRecapInputs): RecapReport { rateLine, `${totals.gateOverrides} maintainer override(s), ${totals.reversals} recommendation reversal(s).`, ].map(sanitizeRecapText); - return { generatedAt: args.generatedAt, windowDays, repos, totals: { ...totals, ...(cohorts ? { cohorts } : {}) }, summary }; + return { + generatedAt: args.generatedAt, + windowDays, + repos, + totals: { ...totals, ...(cohorts ? { cohorts } : {}) }, + // #8214: the drift section exists only when a source was supplied — mirroring the cohorts convention. + ...(args.configDrift ? { configDrift: buildConfigDriftRecapSection(args.configDrift) } : {}), + summary, + }; } /** Redact one free-text line bound for the public digest body. Two arms mirroring weekly-value-report.ts's @@ -188,6 +200,11 @@ export function formatMaintainerRecap(report: RecapReport): string { "", "## Per-repo", ...recapSectionLines(perRepoLines, "_No repositories in this window._"), + "", + "## Config drift", + // #8214: a report built without a drift source still renders the section, with the explicit disabled + // line — absence of data must stay distinguishable from absence of drift. + ...recapSectionLines(report.configDrift?.lines ?? [], "_drift sentinel disabled — no config-drift source supplied to this recap._"), ]; return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; } @@ -217,6 +234,8 @@ export async function runMaintainerRecap( windowDays?: number; generatedAt?: string; repos?: MaintainerRecapRepoInput[]; + /** #8214: config-drift source threaded to {@link buildMaintainerRecap} (ignored when `report` is set). */ + configDrift?: ConfigDriftRecapSource; /** Pre-built report for test injection; skips {@link buildMaintainerRecap} when set. */ report?: RecapReport; /** When explicitly false, short-circuits before build/format/delivery. Default: run. */ @@ -231,6 +250,7 @@ export async function runMaintainerRecap( generatedAt: options.generatedAt ?? nowIso(), windowDays: options.windowDays, repos: options.repos ?? [], + configDrift: options.configDrift, }); const formatted = formatMaintainerRecap(report); const [discord, slack] = await Promise.all([ diff --git a/src/types.ts b/src/types.ts index 1e96b51db..bc6e73730 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2838,6 +2838,22 @@ export type MaintainerRecapRepo = { cohorts?: { miner: MaintainerRecapCohortCounts; human: MaintainerRecapCohortCounts } | undefined; }; +/** The recap's config-drift section (#8214, epic #8211 track A): each live knob's current sentinel drift + * verdict as aggregate numbers + knob ids only — never corpus content. Built by + * buildConfigDriftRecapSection (services/maintainer-recap-config-drift.ts); present on a RecapReport only + * when the caller supplied a drift source. Absent means the sentinel was not consulted for this recap — + * the formatter renders the explicit disabled line for that case, so absence of DATA stays distinguishable + * from absence of DRIFT. */ +export type ConfigDriftRecapSection = { + title: string; + sentinelEnabled: boolean; + driftingKnobs: number; + cleanKnobs: number; + /** Plain-English status line (disabled / no-knobs / drift-present / all-clean). */ + note: string; + lines: string[]; +}; + /** A serializable maintainer recap: a window of loopover's OWN review-outcome data folded across repos. * Foundation for the #1963 recap digest — the pure data-shaping seam only (no delivery, no scheduling). * Distinct from {@link ReviewRecap} (single-repo, sourced from gate merge-precision predictions); this is @@ -2862,5 +2878,8 @@ export type RecapReport = { * contribute to these sums, so a partial-adoption window still degrades gracefully. */ cohorts?: { miner: MaintainerRecapCohortCounts; human: MaintainerRecapCohortCounts } | undefined; }; + /** #8214: the config-drift sentinel section — present only when the recap's caller supplied a drift + * source (the sentinel's plumbing, #8213). Absent ⇒ the formatter renders the explicit disabled line. */ + configDrift?: ConfigDriftRecapSection | undefined; summary: string[]; }; diff --git a/test/unit/maintainer-recap-config-drift.test.ts b/test/unit/maintainer-recap-config-drift.test.ts new file mode 100644 index 000000000..85c65f615 --- /dev/null +++ b/test/unit/maintainer-recap-config-drift.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { + buildConfigDriftRecapSection, + type ConfigDriftKnobStatus, + type ConfigDriftRecapSource, +} from "../../src/services/maintainer-recap-config-drift"; + +const GEN = "2026-07-08T00:00:00.000Z"; + +function source(knobs: ConfigDriftKnobStatus[], overrides: Partial = {}): ConfigDriftRecapSource { + return { sentinelEnabled: true, generatedAt: GEN, knobs, ...overrides }; +} + +function driftingKnob(overrides: Partial = {}): ConfigDriftKnobStatus { + return { + knobId: "ai_review_close_confidence", + report: { direction: "tighter", liveValue: 0.9, dominatingValue: 0.95, visibleCases: 240, heldOutCases: 60 }, + episodeStartedAt: "2026-07-05T00:00:00.000Z", + ...overrides, + }; +} + +describe("buildConfigDriftRecapSection (#8214)", () => { + it("renders the explicit disabled line when the sentinel flag is off (disabled arm)", () => { + // #8214's mandated empty state: absence of DATA must be distinguishable from absence of DRIFT. + const section = buildConfigDriftRecapSection(source([driftingKnob()], { sentinelEnabled: false })); + expect(section.title).toBe("Config drift"); + expect(section.sentinelEnabled).toBe(false); + expect(section.driftingKnobs).toBe(0); + expect(section.cleanKnobs).toBe(0); + expect(section.note).toMatch(/drift sentinel disabled/); + expect(section.lines).toEqual([section.note]); + // The supplied knob must NOT leak through the disabled arm. + expect(section.lines.join("\n")).not.toContain("ai_review_close_confidence"); + }); + + it("reports enabled-but-empty as its own state (no-knobs note arm)", () => { + // The sentinel ran and had nothing to evaluate — distinct from disabled AND from all-clean. + const section = buildConfigDriftRecapSection(source([])); + expect(section.sentinelEnabled).toBe(true); + expect(section.driftingKnobs).toBe(0); + expect(section.cleanKnobs).toBe(0); + expect(section.note).toMatch(/no live knobs were evaluated/); + expect(section.note).not.toMatch(/drift sentinel disabled/); + expect(section.lines).toEqual([section.note]); + }); + + it("collapses clean knobs into one summary line and takes the healthy note (all-clean arm)", () => { + const section = buildConfigDriftRecapSection( + source([driftingKnob({ knobId: "knob_a", report: null, episodeStartedAt: null }), driftingKnob({ knobId: "knob_b", report: null, episodeStartedAt: null })]), + ); + expect(section.driftingKnobs).toBe(0); + expect(section.cleanKnobs).toBe(2); + expect(section.note).toMatch(/Config healthy: all 2 live knob\(s\) clean/); + // ONE summary line for clean knobs (never one row per knob) + the note — nothing else. + expect(section.lines).toEqual(["2 knob(s) clean — no alternative dominates the live value.", section.note]); + expect(section.lines.join("\n")).not.toContain("knob_a"); + }); + + it("renders a drifting knob with direction, live vs dominating, corpus sizes, and standing time beside a clean summary (mixed arm)", () => { + // 2026-07-05 → 2026-07-08 ⇒ the episode has stood exactly 3 days (the days >= 1 ternary arm). + const section = buildConfigDriftRecapSection(source([driftingKnob(), { knobId: "quiet_knob", report: null }])); + expect(section.driftingKnobs).toBe(1); + expect(section.cleanKnobs).toBe(1); + const drift = section.lines[0]; + expect(drift).toContain("ai_review_close_confidence"); + expect(drift).toContain("stale-config warning — a tighter setting dominates live"); + expect(drift).toContain("live 0.9 vs dominating 0.95"); + expect(drift).toContain("(240 visible / 60 held-out case(s))"); + expect(drift).toContain("standing 3 day(s)"); + expect(section.lines[1]).toBe("1 knob(s) clean — no alternative dominates the live value."); + expect(section.note).toMatch(/config drift: 1 of 2 live knob\(s\)/); + expect(section.lines[2]).toBe(section.note); + }); + + it("omits the clean summary line when every knob drifts, and phrases looser/shipped directions distinctly (all-drifting arm)", () => { + const shipped = driftingKnob({ + knobId: "override_knob", + report: { direction: "shipped", liveValue: 0.8, dominatingValue: 0.7, visibleCases: 100, heldOutCases: 25 }, + episodeStartedAt: null, + }); + const looser = driftingKnob({ + knobId: "looser_knob", + report: { direction: "looser", liveValue: 0.9, dominatingValue: 0.85, visibleCases: 120, heldOutCases: 30 }, + episodeStartedAt: "2026-07-07T12:00:00.000Z", + }); + const section = buildConfigDriftRecapSection(source([shipped, looser])); + expect(section.driftingKnobs).toBe(2); + expect(section.cleanKnobs).toBe(0); + // "shipped" is checked before looser/tighter (mirrors KnobDriftReport.direction's own precedence). + expect(section.lines[0]).toContain("drifted override should revert to shipped"); + // Absent fingerprint ⇒ the explicit unknown-age arm, never NaN. + expect(section.lines[0]).toContain("episode age unknown"); + expect(section.lines[1]).toContain("informational — a looser setting dominates"); + // 12h-old episode ⇒ the days < 1 ternary arm. + expect(section.lines[1]).toContain("standing <1 day"); + expect(section.lines.join("\n")).not.toContain("knob(s) clean"); + expect(section.note).toMatch(/config drift: 2 of 2 live knob\(s\)/); + }); + + it("reports episode age unknown when the recap's own generatedAt is unparseable (bad-now arm)", () => { + // A valid fingerprint cannot rescue a broken "now" — both Date.parse arms must be guarded. + const section = buildConfigDriftRecapSection(source([driftingKnob()], { generatedAt: "not-a-timestamp" })); + expect(section.lines[0]).toContain("episode age unknown"); + expect(section.lines[0]).not.toContain("NaN"); + }); + + it("clamps a future fingerprint (clock skew) to a <1 day standing, never a negative age", () => { + const section = buildConfigDriftRecapSection(source([driftingKnob({ episodeStartedAt: "2026-07-09T00:00:00.000Z" })])); + expect(section.lines[0]).toContain("standing <1 day"); + expect(section.lines[0]).not.toMatch(/standing -\d/); + }); + + it("never leaks corpus content: a FULL KnobDriftReport (with per-split comparisons) renders aggregates + ids only (invariant)", () => { + // Structural-compatibility invariant: the sentinel will hand this section real KnobDriftReports + // (loosening-knobs.ts:143). Their per-split BacktestComparison payloads and ruleId must never surface — + // the projection renders aggregate numbers + the knob id, nothing else. + const fullReport = { + knobId: "ai_review_close_confidence", + ruleId: "internal.rule.identifier", + liveValue: 0.9, + dominatingValue: 0.95, + direction: "tighter" as const, + visibleCases: 240, + heldOutCases: 60, + visible: { verdict: "improved", corpusExcerpt: "PR body text from the corpus /home/operator/loopover/case.json" }, + heldOut: { verdict: "unchanged", corpusExcerpt: "held-out corpus excerpt text" }, + }; + const section = buildConfigDriftRecapSection(source([{ knobId: fullReport.knobId, report: fullReport, episodeStartedAt: GEN }])); + const body = section.lines.join("\n"); + expect(body).toContain("ai_review_close_confidence"); + expect(body).not.toContain("corpus excerpt"); + expect(body).not.toContain("PR body text"); + expect(body).not.toContain("/home/operator"); + expect(body).not.toContain("internal.rule.identifier"); + }); + + it("scrubs a path-bearing knob id and caps line length (sanitizer pass)", () => { + // Path FIRST so the scrub is provably applied before the 240-char cap truncates the padding tail. + const section = buildConfigDriftRecapSection( + source([driftingKnob({ knobId: `bad /root/secrets/config.json ${"x".repeat(300)}` })]), + ); + expect(section.lines[0]).toContain(""); + expect(section.lines[0]).not.toContain("/root/secrets/config.json"); + for (const line of section.lines) expect(line.length).toBeLessThanOrEqual(240); + }); +}); diff --git a/test/unit/maintainer-recap-format.test.ts b/test/unit/maintainer-recap-format.test.ts index a798a4c51..d4dad3de8 100644 --- a/test/unit/maintainer-recap-format.test.ts +++ b/test/unit/maintainer-recap-format.test.ts @@ -27,14 +27,18 @@ function emptyReport(): RecapReport { describe("formatMaintainerRecap (#2240)", () => { it("renders the header and every titled section, with fallback lines and an n/a rate for an empty window", () => { const body = formatMaintainerRecap(emptyReport()); - // Header + all three titled section headers render. + // Header + all titled section headers render. expect(body).toContain("# Maintainer recap"); expect(body).toContain("## Summary"); expect(body).toContain("## Totals"); expect(body).toContain("## Per-repo"); + expect(body).toContain("## Config drift"); // Empty sections show a single fallback line instead of dangling under the header. expect(body).toContain("_No summary lines for this window._"); expect(body).toContain("_No repositories in this window._"); + // #8214: a report built with NO drift source renders the explicit disabled line — absence of data must + // stay distinguishable from absence of drift. + expect(body).toContain("_drift sentinel disabled — no config-drift source supplied to this recap._"); // Null rate ⇒ the "n/a" arm. expect(body).toContain("- Gate false positives: 0/0 (n/a)"); expect(body).toContain("- Repos: 0"); @@ -91,6 +95,31 @@ describe("formatMaintainerRecap (#2240)", () => { expect(body).not.toContain("payout"); }); + it("renders the config-drift section's own lines as bullets when the report carries one (#8214)", () => { + const report: RecapReport = { + ...emptyReport(), + configDrift: { + title: "Config drift", + sentinelEnabled: true, + driftingKnobs: 1, + cleanKnobs: 1, + note: "config drift: 1 of 2 live knob(s) have a dominating alternative on the trailing corpus.", + lines: [ + "close_confidence — stale-config warning — a tighter setting dominates live: live 0.9 vs dominating 0.95 (240 visible / 60 held-out case(s)); standing 3 day(s)", + "1 knob(s) clean — no alternative dominates the live value.", + "config drift: 1 of 2 live knob(s) have a dominating alternative on the trailing corpus.", + ], + }, + }; + const body = formatMaintainerRecap(report); + expect(body).toContain("## Config drift"); + // Populated arm: the section's lines render as bullets and the disabled fallback does NOT appear. + expect(body).toContain("- close_confidence — stale-config warning"); + expect(body).toContain("- 1 knob(s) clean — no alternative dominates the live value."); + expect(body).not.toContain("_drift sentinel disabled"); + expect(body).not.toMatch(/\n{3,}/); + }); + it("omits cohort diagnostics from the public recap even when totals.cohorts is present", () => { const report: RecapReport = { ...emptyReport(), diff --git a/test/unit/maintainer-recap.test.ts b/test/unit/maintainer-recap.test.ts index d4dbd79fc..2e68f917a 100644 --- a/test/unit/maintainer-recap.test.ts +++ b/test/unit/maintainer-recap.test.ts @@ -64,6 +64,31 @@ describe("buildMaintainerRecap (#2239)", () => { // blocked === 0 ⇒ rate is null ⇒ the "not enough blocked PRs" summary arm. expect(report.summary[1]).toContain("not enough blocked PRs"); expect(report.summary[0]).toContain("0 repo(s)"); + // #8214: no drift source supplied ⇒ the report carries NO configDrift section (the omitted arm). + expect(report.configDrift).toBeUndefined(); + }); + + it("attaches the config-drift section when a drift source is supplied (#8214 present arm)", () => { + const report = buildMaintainerRecap({ + generatedAt: GEN, + repos: [], + configDrift: { + sentinelEnabled: true, + generatedAt: GEN, + knobs: [ + { + knobId: "close_confidence", + report: { direction: "tighter", liveValue: 0.9, dominatingValue: 0.95, visibleCases: 240, heldOutCases: 60 }, + episodeStartedAt: "2026-07-05T00:00:00.000Z", + }, + { knobId: "quiet_knob", report: null }, + ], + }, + }); + expect(report.configDrift).toBeDefined(); + expect(report.configDrift?.driftingKnobs).toBe(1); + expect(report.configDrift?.cleanKnobs).toBe(1); + expect(report.configDrift?.lines[0]).toContain("close_confidence"); }); it("folds a single repo's counts and computes the gate false-positive rate", () => { @@ -231,6 +256,21 @@ describe("runMaintainerRecap (#2252 end-to-end orchestration)", () => { expect(result.report.repos).toEqual([]); expect(result.formatted).toContain("_No repositories in this window._"); expect(result.formatted).toContain("(n/a)"); + // #8214: no drift source threaded ⇒ the formatted body renders the explicit disabled fallback. + expect(result.formatted).toContain("_drift sentinel disabled — no config-drift source supplied to this recap._"); + }); + + it("threads a config-drift source through to the built report and formatted body (#8214)", async () => { + stubRecapChannelFetch(); + const result = await runMaintainerRecap(envWithBothWebhooks(), { + configDrift: { sentinelEnabled: false, generatedAt: GEN, knobs: [] }, + }); + expect(result.skipped).toBe(false); + if (result.skipped) return; + expect(result.report.configDrift?.sentinelEnabled).toBe(false); + // The builder's disabled arm renders, NOT the formatter's no-source fallback. + expect(result.formatted).toContain("- drift sentinel disabled — no drift data was collected for this window."); + expect(result.formatted).not.toContain("_drift sentinel disabled"); }); it("short-circuits when enabled is false — no build/format/fetch (flag-OFF arm)", async () => {