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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user