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:
275
tests/smart-entry.property.test.cjs
Normal file
275
tests/smart-entry.property.test.cjs
Normal 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`,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user