fix(#2570): parse leading date from last_activity so stale_activity fires with a description suffix (#2571)

* fix(#2570): parse leading date from last_activity so stale_activity fires with a description suffix

templates/state.md prescribes `Last activity: [YYYY-MM-DD] — [What happened]`,
and gsd-core's own STATE.md mirrors that suffix into frontmatter. Date.parse on
the whole string returned NaN, and because staleActivity treats null as "not
stale" (fails open), the only idle/staleness detector never fired on any project
whose last_activity kept its description.

parseActivityTimestamp now reads the leading ISO date/time token when a
whole-string parse fails, validating the calendar date (ADR-227: reject an
impossible date rather than let Date.parse roll it forward) and preferring the
whole-string parse when it succeeds so a trailing zone name is not dropped.

Composes with #3099 (LAST_ACTIVITY_UNPARSEABLE diagnostic), which merged to next
after this branch: both key off parseActivityTimestamp === null, so a value whose
leading date now parses takes the stale path and does NOT emit the diagnostic. A
regression test in tests/smart-entry.unit.test.cjs asserts exactly that (stale
true, emission count 0), guarding against two staleness signals on one field.

Rebased onto next (flattened): resolved the add/add test conflict by keeping both
the #2570 and #3099 describe blocks. Tests: unit + property, 80 pass.

* fix(#2570): fail open when a named zone can't be reconstructed from the token (#2571 B1)

The 2026-08-08 flatten dropped the zone handling earlier rounds built, so the
fallback path -- reached only when a description suffix makes the whole-string
parse fail, the #2570 case -- reconstructed `${date}${time}` WITHOUT any named
zone. ISO_LEADING_RE's offset group captures only Z / +-HH:MM, so " GMT"/" EST"
land in the un-captured suffix; Date.parse then read the reconstruction as LOCAL
time, shifting the instant by the host's UTC offset -- a wrong, host-dependent
value the diff's own comment warned against but guarded only on the other branch.

Fix (the simpler of the two offered in review): when the remainder after the
matched token begins with a letter (a named zone we cannot preserve), return
null -- fail open to not-stale, matching the base's honest behaviour and
ADR-227's "never propagate a wrong instant". The #2570 template suffix
(" -- description") starts with a separator, so it still reconstructs and reads
stale as intended.

Tests (both fail-first, verified RED on the pre-fix head):
- smart-entry.unit: a named-zone + description suffix (54 days old) enters the
  fallback and must read not-stale, not a still-old local instant. Host-
  independent by construction.
- smart-entry.property (f): named-zone + suffix over 1-week..1-year ages and 8
  zones stays total and fails open.

Discloses the removal M2 flagged: TRAILING_ZONE_RE / UTC_ZONE_NAMES /
timeCarriesOffset were dropped by the flatten; this restores the SAFETY (no
wrong instant) via the simpler null contract rather than the allowlist.

* fix(#2570): narrow the stale_activity fallback guard to a zone-designator shape

The round-9 fail-open guard `/^\s*[A-Za-z]/` treated any letter-led remainder as
an unpreservable named zone, so a leading real date followed by a bare
space/tab/colon and an ordinary description (a hand-edited STATE.md that omits the
template em dash) returned null and re-opened #2570 for exactly those shapes.

Narrow the guard to ZONE_DESIGNATOR_RE -- a standalone short all-caps run -- and
consult it ONLY when the leading token captured a time-of-day: a zone qualifies a
clock time, so a bare date carries no zone hazard and always reconstructs to its
UTC midnight. A plain description (including one that opens with a tech acronym
like "CI green") reconstructs; a real named zone on a timed value (GMT/EST/...)
still fails open (ADR-227: never propagate a wrong, host-dependent instant).

Widen the property generator to the non-em-dash separators (space/tab/colon), the
arm that structurally could not reach the fallback before, and add unit cases for
whitespace/tab/colon-separated and bare-date+acronym descriptions. All fail-first
on the prior guard; green across UTC/LA/Tokyo/Kiritimati.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Rezolv
2026-08-12 20:46:12 -04:00
committed by GitHub
parent 6950ae3679
commit 3ff9a7ffcd
4 changed files with 762 additions and 2 deletions

View File

@@ -187,11 +187,104 @@ function phaseTokenFromDirName(name: string): string | null {
/**
* Parse a `last_activity` value that may be an ISO date or a free-form string
* into an epoch-ms timestamp. Returns null when unparseable.
*
* #2570: `last_activity` routinely carries a trailing " — <description>" — the
* shape `templates/state.md` itself prescribes (`Last activity: [YYYY-MM-DD] —
* [What happened]`), which gsd-core's own STATE.md mirrors into frontmatter.
* `Date.parse` on the whole string returns NaN, and because `staleActivity`
* treats null as "not stale" (fails open), the ONLY idle/staleness detector
* never fired on any project whose last_activity retained its description.
* Be liberal in what we accept (Postel): read the leading ISO date/time token
* when the value carries one, so the description suffix — whatever separator
* (em dash or hyphen) it uses — no longer silently blinds the detector; fall
* back to a whole-string parse for any other shape a hand edit might use.
*/
/** Leading ISO date, with an optional time-of-day and offset. */
const ISO_LEADING_RE =
/^(\d{4})-(\d{2})-(\d{2})((?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?)/;
/**
* A NAMED timezone designator at the start of the un-reconstructable remainder
* (#2571). ISO_LEADING_RE's offset group captures only `Z` / `±HH:MM`, so a
* named zone (GMT, EST, ...) is not in the leading token — it sits here.
* Reconstructing the token without it would let Date.parse read the time as
* LOCAL, shifting the instant by the host's offset (a wrong, host-dependent
* value), so a zone-shaped remainder must fail open (ADR-227).
*
* The shape is a short all-caps run (2–5 letters) that stands alone — the
* negative lookahead excludes the first letter of a Capitalised word like
* "Milestone", and an optional trailing offset is subsumed because the leading
* all-caps run already matches. Everything else — a lowercase or Capitalised
* description, a separator — is describable text and is reconstructed from the
* leading date.
*
* Consulted ONLY when the leading token captured a time-of-day (see the caller):
* a zone designator qualifies a clock time, so a BARE date can carry no zone
* hazard — reconstructing it is always just that date's UTC midnight, whatever
* trails it. Gating on the time keeps a description that merely opens with a
* tech acronym ("2026-06-08 CI green", "API refactor") on the reconstruct path
* instead of failing open. The prior "any letter" guard was too liberal — it
* failed open on every letter-led description and re-opened #2570.
*/
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;
const ms = Date.parse(raw);
return Number.isNaN(ms) ? null : ms;
const trimmed = raw.trim();
const iso = trimmed.match(ISO_LEADING_RE);
if (iso) {
const [, year, month, day, time] = iso;
// 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;
// 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
// 12:34:56 GMT" parses whole as 12:34:56Z but as local time from the token,
// shifting the instant by the host's UTC offset.
const whole = Date.parse(trimmed);
if (!Number.isNaN(whole)) return whole;
// Whole-string failed: the value carries a suffix the engine can't read as
// one instant (#2570). Reconstruct from the leading token UNLESS the remainder
// is a named zone the token dropped (GMT, EST, ...): reconstructing without it
// reads the time as LOCAL and shifts the instant by the host's offset, so a
// zone-shaped remainder fails open (ADR-227: never propagate a wrong instant;
// null is the base's not-stale default). An ordinary description -- the #2570
// template's " -- description", or a hand edit's bare-space/tab/colon suffix --
// carries no zone and IS reconstructed. See ZONE_DESIGNATOR_RE for the shape;
// the earlier "any letter" guard failed open on every description and re-opened
// #2570 for whitespace-separated suffixes.
const rest = trimmed.slice(iso[0].length);
if (time && ZONE_DESIGNATOR_RE.test(rest)) return null;
const ms = Date.parse(`${year}-${month}-${day}${time}`);
return Number.isNaN(ms) ? null : ms;
}
const direct = Date.parse(trimmed);
return Number.isNaN(direct) ? null : direct;
}
interface GitSignals {