From 52790250f0ecf610f896a5883840a60b64060b63 Mon Sep 17 00:00:00 2001 From: JakubAnderwald Date: Fri, 18 Sep 2026 22:22:03 +0200 Subject: [PATCH] fix(scripts): compute nightly-audit 24h window in UTC The YESTERDAY cutoff was built in local time but compared as a string against GitHub's UTC createdAt/mergedAt, so on the CET/CEST Mac mini the audit silently dropped the first 1-2 hours of its 24h window. Pass -u to both the BSD (-v) and GNU (-d) date branches, and add a regression test that asserts -u on every date invocation and evaluates the line under a non-UTC TZ. Closes #511 Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/nightly-audit-window.test.mjs | 82 +++++++++++++++++++ scripts/nightly-audit.sh | 6 +- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 scripts/__tests__/nightly-audit-window.test.mjs diff --git a/scripts/__tests__/nightly-audit-window.test.mjs b/scripts/__tests__/nightly-audit-window.test.mjs new file mode 100644 index 00000000..bbaa2123 --- /dev/null +++ b/scripts/__tests__/nightly-audit-window.test.mjs @@ -0,0 +1,82 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Guard for #511: nightly-audit.sh compares its "24 hours ago" cutoff as a +// string against GitHub's UTC createdAt/mergedAt. A local-time cutoff runs +// ahead of UTC on the Mac mini (CET/CEST) and silently drops the first 1–2 +// hours of the window, so every `date` that builds YESTERDAY must pass -u. + +const HERE = dirname(fileURLToPath(import.meta.url)); +const script = readFileSync(resolve(HERE, "..", "nightly-audit.sh"), "utf8"); +const DAY_MS = 24 * 60 * 60 * 1000; + +const yesterdayLines = script.split("\n").filter((line) => /^\s*YESTERDAY=/.test(line)); + +// A `date` command in command position: the start of a $( … ) body or after a +// ||, &&, | or ; separator. Captures its arguments up to the next separator. +const DATE_INVOCATION = + /(?:\$\(|\|\||&&|[|;])\s*date(?![\w-])((?:\s+(?:'[^']*'|"[^"]*"|[^\s'"|;&)]+))*)/g; + +function dateInvocations(line) { + return [...line.matchAll(DATE_INVOCATION)].map( + (m) => m[1].trim().match(/'[^']*'|"[^"]*"|\S+/g) ?? [], + ); +} + +describe("nightly-audit YESTERDAY cutoff", () => { + it("is assigned exactly once", () => { + assert.equal( + yesterdayLines.length, + 1, + "expected exactly one YESTERDAY= assignment in nightly-audit.sh", + ); + }); + + it("passes -u to every date invocation", () => { + const [line] = yesterdayLines; + const invocations = dateInvocations(line); + // Every whole-word `date` on the line must be one we parsed, so a `date` in + // an unexpected position fails loudly instead of escaping the -u check. + const bareDates = line.match(/(?= 2, + `expected the macOS (-v) and GNU (-d) date branches, found ${invocations.length}`, + ); + for (const args of invocations) { + assert.ok( + args.includes("-u"), + `date ${args.join(" ")} must pass -u to compare against GitHub's UTC timestamps`, + ); + } + }); + + it("evaluates to the UTC time 24h ago regardless of the local timezone", () => { + const [line] = yesterdayLines; + // POSIX TZ string (UTC+05:45, no DST) so the check needs no tzdata; a + // local-time cutoff would land 5h45m away from the UTC one. + const cutoff = execFileSync("bash", ["-c", `${line}\nprintf '%s' "$YESTERDAY"`], { + env: { ...process.env, TZ: "XYZ-05:45" }, + encoding: "utf8", + }); + assert.match(cutoff, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + + const cutoffMs = Date.parse(`${cutoff}Z`); + const drift = Math.abs(cutoffMs - (Date.now() - DAY_MS)); + assert.ok(drift < 60_000, `cutoff ${cutoff} is ${drift}ms away from UTC now-24h`); + + // jq's string >= against GitHub's Z-suffixed form keeps the boundary second. + const github = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z"); + assert.ok(github(cutoffMs) >= cutoff); + assert.ok(github(cutoffMs + 1000) >= cutoff); + assert.ok(!(github(cutoffMs - 1000) >= cutoff)); + }); +}); diff --git a/scripts/nightly-audit.sh b/scripts/nightly-audit.sh index 1b9d7908..d99f3b7b 100755 --- a/scripts/nightly-audit.sh +++ b/scripts/nightly-audit.sh @@ -40,8 +40,10 @@ trap cleanup EXIT log "=== Nightly audit started ===" -# 24-hours-ago timestamp for filtering (macOS date -v) -YESTERDAY=$(date -v-24H +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -d '24 hours ago' +%Y-%m-%dT%H:%M:%S) +# 24-hours-ago timestamp for filtering (macOS date -v, GNU date -d fallback). +# UTC (-u) to match GitHub's Z-suffixed createdAt/mergedAt, which the jq filters +# below compare as strings — a local-time cutoff would silently shrink the window. +YESTERDAY=$(date -u -v-24H +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%S) # ── Section A: Stage 1 — Issue Creation Health ── log "--- Section A: Issue Creation Health ---"