diff --git a/src/core/audit-parser-probe.ts b/src/core/audit-parser-probe.ts index ac83d985d..597dfc176 100644 --- a/src/core/audit-parser-probe.ts +++ b/src/core/audit-parser-probe.ts @@ -37,7 +37,13 @@ export function readRecentParserProbeEvents( days = 7, now: Date = new Date(), ): ParserProbeAuditEvent[] { - return writer.readRecent(days, now); + // Chronological order (oldest → newest). The shared reader walks the + // CURRENT week's file first, then the previous week's, so without sorting + // the array tail is the OLDEST in-window event whenever last week's file + // has entries — and doctor's "latest" (which reads the tail) reported a + // days-old run while counts included the newest one. + return writer.readRecent(days, now) + .sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts)); } /** Exposed for tests pinning the rotation edge cases. */ diff --git a/src/core/audit-quality-probe.ts b/src/core/audit-quality-probe.ts index 9bddbd9f5..135e138a4 100644 --- a/src/core/audit-quality-probe.ts +++ b/src/core/audit-quality-probe.ts @@ -118,5 +118,10 @@ export function readRecentQualityProbeEvents( } } } - return out; + // Chronological order (oldest → newest). Events accumulate across two + // week files read current-week-FIRST, so without sorting the array tail + // is the OLDEST in-window event whenever last week's file has entries — + // and doctor's "Latest:" (which reads the tail) reported a days-old run + // while the counts included the newest one. + return out.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts)); } diff --git a/test/audit-parser-probe.serial.test.ts b/test/audit-parser-probe.serial.test.ts index 9625c80e3..7902a2abe 100644 --- a/test/audit-parser-probe.serial.test.ts +++ b/test/audit-parser-probe.serial.test.ts @@ -6,7 +6,7 @@ * the env override is process-global. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { mkdtempSync, rmSync, readdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -73,6 +73,33 @@ describe('parser-probe audit trail', () => { logParserProbeEvent(makeEvent({ ts: old })); expect(readRecentParserProbeEvents(7).length).toBe(0); }); + + test('cross-week ordering: events come back chronological so the tail is the newest run', () => { + // Regression: the shared week-file reader walks the current week's file + // first, then the previous week's. Without sorting, the array tail — + // which doctor's conversation_parser_probe_health reports as "latest" — + // was the OLDEST in-window event whenever last week's file had entries. + // Write the two week files directly so the cross-file case is genuinely + // exercised (the writer routes by write time, not event ts). + const now = new Date('2026-07-23T12:00:00Z'); + const thisWeekFile = computeParserProbeAuditFilename(now); + const prevWeekFile = computeParserProbeAuditFilename(new Date(now.getTime() - 7 * 86400000)); + writeFileSync(join(auditDir, thisWeekFile), [ + JSON.stringify(makeEvent({ ts: '2026-07-22T08:00:00Z' })), + JSON.stringify(makeEvent({ ts: '2026-07-23T08:00:00Z' })), + ].join('\n') + '\n'); + writeFileSync(join(auditDir, prevWeekFile), [ + JSON.stringify(makeEvent({ ts: '2026-07-17T08:00:00Z' })), + JSON.stringify(makeEvent({ ts: '2026-07-18T08:00:00Z' })), + ].join('\n') + '\n'); + const events = readRecentParserProbeEvents(7, now); + expect(events.map(e => e.ts)).toEqual([ + '2026-07-17T08:00:00Z', + '2026-07-18T08:00:00Z', + '2026-07-22T08:00:00Z', + '2026-07-23T08:00:00Z', + ]); + }); }); describe('parserProbeRanWithin — 24h rate-limit gate', () => { diff --git a/test/nightly-quality-probe.test.ts b/test/nightly-quality-probe.test.ts index e7839e0ab..8ce7ad660 100644 --- a/test/nightly-quality-probe.test.ts +++ b/test/nightly-quality-probe.test.ts @@ -298,6 +298,39 @@ describe('computeNightlyQualityProbeHealthCheck — pure doctor branch coverage' expect(check.message).toMatch(/1 non-PASS run /); // "run " not "runs " }); + test('cross-week ordering: reader sorts chronologically so "Latest" is the newest run', async () => { + // Regression: the reader walks the CURRENT week's file first, then the + // previous week's. Without sorting, the array tail — which this check + // reports as "Latest:" — was the OLDEST in-window event whenever last + // week's file had entries (observed live: counts updated as new runs + // landed while "Latest" stayed pinned days behind). + const { computeQualityProbeAuditFilename, readRecentQualityProbeEvents } = + await import('../src/core/audit-quality-probe.ts'); + const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts'); + const now = new Date('2026-07-23T12:00:00Z'); + const thisWeekFile = computeQualityProbeAuditFilename(now); + const prevWeekFile = computeQualityProbeAuditFilename(new Date(now.getTime() - 7 * 86400000)); + writeFileSync(join(auditTmp, thisWeekFile), [ + JSON.stringify({ outcome: 'fail', ts: '2026-07-22T08:00:00Z' }), + JSON.stringify({ outcome: 'fail', ts: '2026-07-23T08:00:00Z' }), + ].join('\n') + '\n'); + writeFileSync(join(auditTmp, prevWeekFile), [ + JSON.stringify({ outcome: 'fail', ts: '2026-07-17T08:00:00Z' }), + JSON.stringify({ outcome: 'fail', ts: '2026-07-18T08:00:00Z' }), + ].join('\n') + '\n'); + await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => { + const events = readRecentQualityProbeEvents(7, now); + expect(events.map(e => e.ts)).toEqual([ + '2026-07-17T08:00:00Z', + '2026-07-18T08:00:00Z', + '2026-07-22T08:00:00Z', + '2026-07-23T08:00:00Z', + ]); + const check = computeNightlyQualityProbeHealthCheck(true, events); + expect(check.message).toContain('Latest: fail at 2026-07-23T08:00:00Z'); + }); + }); + test('single PASS event uses singular grammar', async () => { const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts'); const events = [{ outcome: 'pass', ts: '2026-05-22T03:00:00Z' }];