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

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2571
---
**The idle/staleness detector now fires when `last_activity` carries a description** — a `last_activity` written in the shape `templates/state.md` prescribes (`[YYYY-MM-DD] — [What happened]`) parsed to `NaN`, and because the detector treats an unparseable value as "not stale" it failed open to `false`. Any project whose `last_activity` kept its description was never reported idle, no matter how long it had sat. The leading date is now parsed out of the value, so the description no longer blinds the only staleness signal in the front door. An impossible calendar date such as `2026-02-30` is now rejected outright rather than silently rolling forward to a real — and wrong — date. (#2570)

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 {

View File

@@ -0,0 +1,275 @@
'use strict';
/**
* Property-based tests for smart-entry's `last_activity` staleness detection.
*
* Module: src/smart-entry.cts (built to gsd-core/bin/lib/smart-entry.cjs)
* Exercised surface: detectSignals(cwd, now) -> { stale_activity, ... }
*
* These drive the REAL frontmatter -> fmScalar -> parseActivityTimestamp ->
* staleActivity chain rather than the parser in isolation, because that whole
* chain is where #2570 actually failed: the parser is private, and asserting on
* detectSignals' typed result is the surface ADR-456's typed-surface mandate
* asks for (never rendered text or source literals).
*
* Properties tested:
* (a) suffix-invariance — for ANY description suffix the template shape can
* produce, a last_activity older than IDLE_STALE_MS reads stale. The
* description must never change the parsed instant. This is #2570's
* invariant: pre-fix, Date.parse on the whole string returned NaN and
* staleActivity failed OPEN to false for every one of these inputs.
* (b) no false positives — the same generated suffixes on a RECENT date must
* still read not-stale, so (a) cannot be satisfied by a parser that
* simply reports everything stale.
* (c) total function — detectSignals never throws and stale_activity is
* always a boolean, for arbitrary junk in last_activity.
* (d) threshold-exactness — driven with a FULL ISO instant rather than a
* bare date, stale_activity equals `age > IDLE_STALE_MS` across the
* whole range. (a)/(b) deliberately skip the [24, 95]h band because a
* date-only value truncates to UTC midnight and cannot express limit±1;
* (d) closes that band, including 71/72/73h, against an exact oracle.
* (e) calendar validity — a shape-valid date whose day cannot exist never
* yields a timestamp. Date.parse rolls those FORWARD (2026-02-30 ->
* 2026-03-02), so shape-only validation would propagate a wrong instant
* instead of failing safe (ADR-227).
*
* IDLE_STALE_MS is 72h (src/smart-entry.cts). The clock is injected, so these
* never depend on wall time.
*/
const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const fc = require('./helpers/fast-check-setup.cjs');
const { cleanup } = require('./helpers.cjs');
const { detectSignals } = require('../gsd-core/bin/lib/smart-entry.cjs');
const FIXED_NOW = () => Date.parse('2026-08-01T00:00:00Z');
const HOUR_MS = 3600 * 1000;
/** Mirrors IDLE_STALE_MS (src/smart-entry.cts:121). The oracle for (d). */
const IDLE_STALE_MS = 72 * HOUR_MS;
/**
* Shape-valid dates whose DAY cannot exist. All are in the past relative to
* FIXED_NOW, so a rolled-forward parse would read stale=true — which is what
* makes the property discriminating rather than vacuous.
*/
const IMPOSSIBLE_DAYS = [
'2026-02-30',
'2026-02-31',
'2026-04-31',
'2026-06-31',
'2025-02-29', // 2025 is not a leap year
'2025-11-31',
'2024-04-31',
'2023-06-31',
];
const created = [];
function makeProject(lastActivity) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-se-prop-'));
created.push(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'status: executing',
`last_activity: ${lastActivity}`,
'---',
'',
'# Project State',
'',
'Phase: 3',
'',
].join('\n'),
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
return tmpDir;
}
afterEach(() => {
while (created.length) {
// helpers.cleanup carries the Windows-EBUSY retry budget; raw fs.rmSync in
// a test is banned by local/no-raw-rmsync-in-tests.
cleanup(created.pop());
}
});
/** An ISO `YYYY-MM-DD` date a generated number of hours before FIXED_NOW. */
const dateOffsetHours = (hours) =>
new Date(FIXED_NOW() - hours * HOUR_MS).toISOString().slice(0, 10);
/**
* The description suffix `templates/state.md` prescribes: a separator (em dash
* or hyphen, as both appear in the wild) followed by free text. Constrained to
* the template's real shape — an unconstrained suffix could append a second
* timestamp and legitimately change the parsed instant, which is not the
* contract this asserts.
*/
const dashSuffix = fc
.tuple(
fc.constantFrom(' — ', ' - ', ' — '),
fc.string({ minLength: 1, maxLength: 60 }).filter((s) => !s.includes('\n')),
)
.map(([sep, text]) => `${sep}${text}`);
/**
* #2571: the separators a hand edit uses WITHOUT the em dash — a bare space, a
* tab, a colon. The pre-fix fallback guard failed open on any letter-leading
* remainder, so these silently re-opened #2570 while the dash-only generator
* above could not reach the shape. The description text is forced to lead with a
* lowercase letter (`x`) so it is unambiguously a description and never a zone
* designator — an all-caps leading run IS genuinely zone-ambiguous and correctly
* fails open, which property (f) covers separately.
*/
const plainSeparatorSuffix = fc
.tuple(
fc.constantFrom(' ', '\t', ': '),
fc.string({ minLength: 0, maxLength: 59 }).filter((s) => !s.includes('\n')),
)
.map(([sep, text]) => `${sep}x${text}`);
const descriptionSuffix = fc.oneof(dashSuffix, plainSeparatorSuffix);
describe('smart-entry stale_activity — properties (#2570)', () => {
test('(a) a stale date reads stale regardless of the description suffix', () => {
fc.assert(
fc.property(
// Strictly older than the 72h threshold, bounded so the date stays valid.
fc.integer({ min: 96, max: 24 * 365 }),
descriptionSuffix,
(hoursAgo, suffix) => {
const signals = detectSignals(
makeProject(`${dateOffsetHours(hoursAgo)}${suffix}`),
FIXED_NOW,
);
assert.equal(
signals.stale_activity,
true,
`last_activity ${hoursAgo}h old with a description suffix must read stale, not fail open`,
);
},
),
);
});
test('(b) a recent date reads not-stale regardless of the description suffix', () => {
fc.assert(
fc.property(
// Same calendar day as FIXED_NOW, so the date-only value is < 72h old
// even after truncation to midnight.
fc.integer({ min: 0, max: 23 }),
descriptionSuffix,
(hoursAgo, suffix) => {
const signals = detectSignals(
makeProject(`${dateOffsetHours(hoursAgo)}${suffix}`),
FIXED_NOW,
);
assert.equal(
signals.stale_activity,
false,
'a same-day last_activity must not be reported stale',
);
},
),
);
});
// #2571: a NAMED timezone (GMT/EST/...) is not captured by ISO_LEADING_RE's
// offset group, so with a description suffix it lands in the un-reconstructable
// remainder and drives the fallback branch. The fix fails open to not-stale
// rather than reconstruct a host-dependent local instant. min age = 1 week so
// a ±14h zone error can never cross the 72h boundary on any host — the pre-fix
// reconstruction reads stale=true everywhere, making this fail-first.
const namedZone = fc.constantFrom('GMT', 'UTC', 'EST', 'CST', 'PST', 'CET', 'IST', 'JST');
test('(f) a named zone + description suffix fails open to not-stale for any zone/age', () => {
fc.assert(
fc.property(
fc.integer({ min: 24 * 7, max: 24 * 365 }),
fc.integer({ min: 0, max: 23 }),
namedZone,
descriptionSuffix,
(hoursAgo, hod, zone, suffix) => {
const hh = String(hod).padStart(2, '0');
const value = `${dateOffsetHours(hoursAgo)}T${hh}:30:00 ${zone}${suffix}`;
const signals = detectSignals(makeProject(value), FIXED_NOW);
assert.equal(
typeof signals.stale_activity,
'boolean',
'must stay total for zone-designator inputs',
);
assert.equal(
signals.stale_activity,
false,
`a named zone (${zone}) the reconstruction cannot preserve must fail open to ` +
`not-stale, never a host-dependent wrong instant (value: ${value})`,
);
},
),
);
});
test('(c) arbitrary last_activity never throws; stale_activity stays a boolean', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 80 }).filter((s) => !s.includes('\n')),
(junk) => {
const signals = detectSignals(makeProject(junk), FIXED_NOW);
assert.equal(
typeof signals.stale_activity,
'boolean',
'stale_activity must remain a boolean for unparseable input',
);
},
),
);
});
test('(d) with an exact instant, stale_activity tracks the 72h threshold across the whole range', () => {
fc.assert(
fc.property(
// The FULL range, including the [24, 95]h band (a) and (b) skip and the
// limit itself. Properties (a)/(b) use bare dates, which truncate to UTC
// midnight and so cannot address the threshold; a full ISO instant can.
fc.integer({ min: 0, max: 24 * 365 }),
descriptionSuffix,
(hoursAgo, suffix) => {
const instant = new Date(FIXED_NOW() - hoursAgo * HOUR_MS).toISOString();
const signals = detectSignals(makeProject(`${instant}${suffix}`), FIXED_NOW);
assert.equal(
signals.stale_activity,
hoursAgo * HOUR_MS > IDLE_STALE_MS,
`an instant ${hoursAgo}h old must read stale=${hoursAgo * HOUR_MS > IDLE_STALE_MS} ` +
'against the strict 72h comparison',
);
},
),
);
});
test('(e) an impossible calendar date never yields a timestamp', () => {
fc.assert(
fc.property(
// Day-in-month overflows only: a shape-valid date whose day cannot
// exist. Date.parse rolls these FORWARD, so a shape-only guard would
// substitute a real — and wrong — instant instead of failing safe.
fc.constantFrom(...IMPOSSIBLE_DAYS),
descriptionSuffix,
(date, suffix) => {
const signals = detectSignals(makeProject(`${date}${suffix}`), FIXED_NOW);
assert.equal(
signals.stale_activity,
false,
`${date} does not exist; it must coerce to the safe default, not roll forward`,
);
},
),
);
});
});

View File

@@ -551,6 +551,393 @@ describe('#2427 — roadmap-grounded completion + tightened status regex', () =>
});
});
describe('smart-entry: stale_activity honors the template\'s "date — description" shape (#2570)', () => {
afterEach(removeAll);
// A fixed "now" far enough past 2026-06-08 that any real date there is well
// beyond IDLE_STALE_MS (72h). Injected so the test is deterministic and does
// not depend on the wall clock.
const FIXED_NOW = () => Date.parse('2026-08-01T00:00:00Z');
// gsd-core's own STATE.md carries last_activity as "YYYY-MM-DD — <description>"
// (templates/state.md prescribes `Last activity: [YYYY-MM-DD] — [What happened]`
// for the body; the frontmatter mirrors it). Before the fix, parseActivityTimestamp
// ran Date.parse on the whole string → NaN → staleActivity failed OPEN to false,
// so the ONLY idle/staleness detector never fired on any project whose
// last_activity retained its description.
test('frontmatter last_activity with " — description" suffix is detected stale', () => {
const stateMd = [
'---',
'gsd_state_version: 1.0',
'status: executing',
'last_activity: 2026-06-08 — Milestone 2 executed autonomously (all passed)',
'progress:',
' total_phases: 5',
' percent: 40',
'---',
'',
'# Project State',
'',
'Phase: 3',
'',
'**Status:** executing',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(
signals.stale_activity,
true,
'a 54-day-old last_activity carrying a description must read stale, not fail open to false',
);
});
test('body "Last activity: <date> — <desc>" fallback is detected stale', () => {
const stateMd = [
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 of 1 (X)',
'Status: In progress',
'Last activity: 2026-06-08 — started the widget',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(signals.stale_activity, true, 'body-field fallback must also parse the leading date');
});
test('bare ISO date (control) still reads stale', () => {
const stateMd = [
'---',
'status: executing',
'last_activity: 2026-06-08',
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(signals.stale_activity, true, 'bare-date parsing must be unchanged');
});
test('recent activity with a description is NOT stale (no false positive)', () => {
const stateMd = [
'---',
'status: executing',
'last_activity: 2026-07-31 — shipped a thing',
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(
signals.stale_activity,
false,
'a next-day activity with a description must NOT be flagged stale',
);
});
// Boundary coverage for the fallback branch. NOTE: these are NOT fail-first
// regressions — a malformed or empty value returned null before the fix too.
// They pin the degrade-safely contract so a future change to the leading-date
// regex cannot start throwing, or start guessing, on unparseable input.
for (const [label, value] of [
['a malformed leading date', '2026-13-45 — nonsense month and day'],
['a non-date prefix', 'yesterday — did some work'],
['an empty value', ''],
['a whitespace-only value', ' '],
]) {
test(`${label} degrades to not-stale without throwing`, () => {
const stateMd = [
'---',
'status: executing',
`last_activity: ${value}`,
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
let signals;
assert.doesNotThrow(() => {
signals = detectSignals(dir, FIXED_NOW);
}, `${label} must not throw`);
// Unparseable reads as not-stale because staleActivity treats null as
// "not stale". That fail-open is pre-existing and out of scope for #2570
// (which is fenced to the description-suffix parse); asserted here so the
// behavior is recorded rather than silently assumed.
assert.equal(
signals.stale_activity,
false,
`${label} must degrade to not-stale, not throw or guess`,
);
});
}
// ADR-227: shape validation alone is not enough. Date.parse rolls an
// out-of-range DAY forward instead of rejecting it, so a shape-only guard
// propagates a different, wrong instant rather than failing safe. The
// pre-existing '2026-13-45' case above only exercises an invalid MONTH,
// which Date.parse happens to reject outright — it cannot catch this class.
//
// Only the two BARE cases are fail-first. On pre-fix code '2026-02-30'
// parses to 2026-03-02 and '2026-06-31' to 2026-07-01 — both read
// stale=true where the fix now reads false.
//
// The two suffixed cases are NOT fail-first: the trailing description
// already makes the pre-fix whole-string Date.parse return NaN, so
// stale_activity is false both before and after. They are kept because
// they pin the new calendar-validity behaviour for the suffix-carrying
// shape templates/state.md prescribes — but they do not demonstrate the
// regression, and should not be cited as if they did.
for (const [label, value] of [
['Feb 30 with a description', '2026-02-30 — fat-fingered the day'],
['Feb 30 bare', '2026-02-30'],
['Apr 31 with a description', '2026-04-31 — thirty days hath September'],
['Jun 31 bare', '2026-06-31'],
]) {
test(`an impossible calendar date (${label}) fails safe instead of rolling forward`, () => {
const stateMd = [
'---',
'status: executing',
`last_activity: ${value}`,
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(
signals.stale_activity,
false,
`${label} must coerce to the safe default, not a rolled-forward instant`,
);
});
}
test('a real leap day still parses (the guard must not over-reject)', () => {
const stateMd = [
'---',
'status: executing',
'last_activity: 2024-02-29 — leap day is a real date',
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(
signals.stale_activity,
true,
'a valid Feb 29 in a leap year must parse and read stale, not be rejected',
);
});
// RULESET.TESTS.boundary-coverage on IDLE_STALE_MS (72h). The comparison is a
// strict `now() - lastActivityMs > IDLE_STALE_MS`, so exactly-72h is NOT
// stale. Full ISO instants (not bare dates) are used deliberately: a bare
// date truncates to UTC midnight, which cannot express limit±1.
//
// NOT fail-first — these pass pre-fix too. They close the [24,95]h band the
// property tests skip, so an off-by-one in the threshold cannot land green.
for (const [label, value, expected] of [
['71h — one hour inside the window', '2026-07-29T01:00:00Z — 71h ago', false],
['72h — exactly at the limit (strict >)', '2026-07-29T00:00:00Z — 72h ago', false],
['73h — one hour past the limit', '2026-07-28T23:00:00Z — 73h ago', true],
]) {
test(`staleness boundary: ${label} -> stale=${expected}`, () => {
const stateMd = [
'---',
'status: executing',
`last_activity: ${value}`,
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(
signals.stale_activity,
expected,
`${label} must read stale=${expected} against the 72h threshold`,
);
});
}
// Leniency guard. The calendar check must not narrow what already parsed:
// reading the leading token in preference to the whole string would DROP a
// trailing zone name and re-read the time as local, shifting the instant by
// the host's UTC offset. Pinned with a value whose verdict flips if that
// happens on a host east of UTC.
test('a trailing zone name is still honored, not dropped for the leading token', () => {
const stateMd = [
'---',
'status: executing',
'last_activity: 2026-07-28 23:30:00 GMT',
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
// 2026-07-28T23:30:00Z is 72.5h before FIXED_NOW -> stale.
assert.equal(
signals.stale_activity,
true,
'GMT must be read as UTC; dropping it re-reads the time as local and moves the instant',
);
});
// #2571 B1 (fail-first): the SAME trailing zone but WITH a description suffix.
// The suffix makes the whole-string Date.parse fail (the #2570 premise), so
// control reaches the FALLBACK — the branch the test above never exercises,
// and the exact gap the round-4 review flagged. ISO_LEADING_RE's offset group
// captures only Z / +-HH:MM, so " GMT" is not in the leading token;
// reconstructing `${date}${time}` without it and letting Date.parse read the
// result as LOCAL time produces a wrong, host-dependent instant. The fix
// returns null (fails open to not-stale, ADR-227) instead of guessing.
//
// Fail-first and host-independent: on pre-fix code the reconstruction yields a
// still-~54-day-old local instant on ANY host (±14h can't cross 72h at that
// age) -> stale=true; the fix -> null -> false.
test('#2571: a named zone + description suffix fails open to not-stale, never a wrong local-time instant', () => {
const stateMd = [
'---',
'status: executing',
'last_activity: 2026-06-08T12:00:00 GMT — Milestone 2 executed autonomously',
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(
signals.stale_activity,
false,
'an unpreservable named zone must fail open to not-stale (null), not reconstruct a ' +
'local-time instant whose verdict depends on the host UTC offset',
);
});
// #2571 (round-10, fail-first): a leading real date followed by a
// WHITESPACE/COLON separator and an ordinary description — no zone designator.
// The pre-fix fallback guard ("/^\\s*[A-Za-z]/", return null on any letter)
// could not tell a named zone from a plain word, so it failed open on exactly
// these and silently re-opened #2570 for hand-edited STATE.md that omits the
// template em dash. The narrowed guard reconstructs from the leading date;
// only a zone-shaped remainder (short all-caps run) still fails open.
//
// Fail-first and host-independent: `2026-06-08` is ~54 days before FIXED_NOW,
// so the reconstructed UTC-midnight instant reads stale on any host; the
// pre-fix guard returns null -> not-stale, inverting the verdict.
// The last two lead with an all-caps tech acronym on a BARE date: a zone
// qualifies a clock time, so a date with no time-of-day carries no zone hazard
// and must reconstruct. A time-agnostic all-caps guard would fail open on these.
for (const value of [
'last_activity: 2026-06-08 caught up on backlog', // space + lowercase
'last_activity: 2026-06-08\tfixed the login bug', // tab + lowercase
'last_activity: 2026-06-08 Milestone two executed', // space + Capitalised word
'last_activity: 2026-06-08: reviewed the PR queue', // colon separator
'last_activity: 2026-06-08 CI green, shipped it', // bare date + acronym lead
'last_activity: 2026-06-08 API refactor complete', // bare date + acronym lead
]) {
test(`#2571: a plain description after "${value.slice(29, 45)}…" reads stale, not fail-open`, () => {
const stateMd = ['---', 'status: executing', value, '---', '', '# Project State', '', 'Phase: 1', ''].join(
'\n',
);
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(
signals.stale_activity,
true,
'a leading real date with a non-zone description suffix must reconstruct and read ' +
'stale, not fail open the way the "any letter" guard did',
);
});
}
// CONTRIBUTING.md QA Matrix: "Mixed CRLF/LF newlines" for frontmatter parsing
// changes. No live defect — the fallback branch trims before matching — but
// the standard asks for the fixture, and this pins it.
test('a CRLF-terminated STATE.md parses the suffixed date identically', () => {
const stateMd = [
'---',
'status: executing',
'last_activity: 2026-06-08 — started the widget',
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\r\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(
signals.stale_activity,
true,
'CRLF line endings must not change the parsed instant',
);
});
// #2570 × #3099 composition: a suffixed value whose LEADING DATE parses must
// take the stale path AND must NOT emit LAST_ACTIVITY_UNPARSEABLE. Guards
// against two independent staleness signals firing on one field — #3099's
// diagnostic is for genuinely unusable values, and #2570 makes this shape
// usable, so the diagnostic must stay silent here.
test('suffixed-but-parseable last_activity is stale and emits NO diagnostic (composes with #3099)', () => {
const {
_resetUnusableInputWarningsForTests,
_unusableInputEmissionCountForTests,
} = require('../gsd-core/bin/lib/unusable-input.cjs');
_resetUnusableInputWarningsForTests();
const stateMd = [
'---',
'status: executing',
'last_activity: 2026-06-08 — Milestone 2 executed autonomously',
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
const dir = track(makeProject({ state: stateMd, roadmap: true }));
const signals = detectSignals(dir, FIXED_NOW);
assert.equal(signals.stale_activity, true,
'a suffixed value whose leading date parses must read stale');
assert.equal(_unusableInputEmissionCountForTests(), 0,
'a now-parseable value must NOT emit LAST_ACTIVITY_UNPARSEABLE (no double staleness signal)');
});
});
// ─── #2573: STATE.md commit-age freshness signal ─────────────────────────────
describe('detectSignals — state_head commit-age freshness (#2573)', () => {