fix(#3696): report the last_activity invariant and make the verdict gateable with --strict (#3844)

* test(#3696): failing-first coverage for the last_activity invariant and --strict exit status

* fix(#3696): report the last_activity invariant and make the verdict gateable with --strict

* fix(#3696): agree with the real reader on last_activity, and stop reporting structure as truncation

* chore(#3696): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-24 21:33:48 -04:00
committed by GitHub
parent 6905726d9c
commit 394bf384be
12 changed files with 954 additions and 46 deletions

View File

@@ -33,6 +33,10 @@ import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { collectSection } from './markdown-sectionizer.cjs';
// #3696: the calendar-validity predicate moved to the STATE.md document module
// so `state validate` can assert the same `last_activity` invariant this reader
// already enforces (ADR-227). Two copies would let the two surfaces disagree
// about whether a STATE.md is usable — which is the defect #3696 reports.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output } = ioMod;
@@ -229,27 +233,6 @@ const ISO_LEADING_RE =
*/
const ZONE_DESIGNATOR_RE = /^\s*[A-Z]{2,5}(?![A-Za-z])/;
/**
* True only when y/m/d name a date that actually exists on the calendar.
*
* `Date.parse` validates shape but not value: it rolls an out-of-range day
* FORWARD rather than rejecting it (`2026-02-30` -> `2026-03-02`,
* `2026-04-31` -> `2026-05-01`). Shape-only validation would therefore
* propagate a different, wrong instant instead of failing safe — precisely
* what ADR-227 ("validate shape AND value; on failure of either layer coerce
* to the contract's safe default, never propagate") exists to prevent. A
* round-trip through Date.UTC detects the rollover: any component the
* constructor normalised comes back changed.
*/
function isRealCalendarDate(year: number, month: number, day: number): boolean {
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
const probe = new Date(Date.UTC(year, month - 1, day));
return (
probe.getUTCFullYear() === year &&
probe.getUTCMonth() === month - 1 &&
probe.getUTCDate() === day
);
}
function parseActivityTimestamp(raw: string | null): number | null {
if (!raw) return null;
@@ -260,7 +243,7 @@ function parseActivityTimestamp(raw: string | null): number | null {
// Reject an impossible calendar date outright rather than letting
// Date.parse substitute a rolled-forward one. null = "no activity signal",
// the safe default staleActivity already fails open on.
if (!isRealCalendarDate(Number(year), Number(month), Number(day))) return null;
if (!stateDocument.isRealCalendarDate(Number(year), Number(month), Number(day))) return null;
// The date is real, so stay as liberal as before (Postel): a whole-string
// parse still wins when the engine can make sense of the value. Reading the
// token first would silently DROP a trailing zone name -- "2026-06-08