fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic for unusable last_activity (#3139)

* fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic when last_activity is present but unparseable

parseActivityTimestamp returned null for both absent AND present-but-unusable
last_activity values, silently suppressing the idle-stranded recommendation.
Per ADR-1411's amendment (corrupt is not absent), the fallback stays but a
diagnostic is now emitted via the warnUnusableInput seam.

- Added LAST_ACTIVITY_UNPARSEABLE to UNUSABLE_REASON enum + prose
- Wired warnUnusableInput into detectSignals when lastActivityRaw is truthy
  but parseActivityTimestamp returned null
- Updated UNUSABLE_REASON lock test
- Added 5 regression tests (unusable→diagnostic, absent→silent, well-formed→silent, dedup)

* chore(#3099): add changeset fragment

* chore(#3099): backfill changeset PR number 3139

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-07 05:32:49 -04:00
committed by GitHub
parent 3146ff36aa
commit 3c1e358a2d
5 changed files with 114 additions and 1 deletions

View File

@@ -549,3 +549,89 @@ describe('#2427 — roadmap-grounded completion + tightened status regex', () =>
`legacy fallback must still reject completion when current_phase < total_phases. Got: ${result.situation}`);
});
});
// ---------------------------------------------------------------------------
// #3099: unusable last_activity emits a diagnostic (ADR-1411 amendment:
// corrupt is not absent — the fallback stays, the silence is the defect)
// ---------------------------------------------------------------------------
describe('#3099: unusable last_activity emits a diagnostic', () => {
const {
_resetUnusableInputWarningsForTests,
_unusableInputEmissionCountForTests,
} = require('../gsd-core/bin/lib/unusable-input.cjs');
function makeStateWithActivity(activity) {
return [
'---',
'status: executing',
`last_activity: ${activity}`,
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n');
}
test('unusable last_activity still resolves stale_activity: false (fallback unchanged)', () => {
_resetUnusableInputWarningsForTests();
const dir = track(makeProject({
state: makeStateWithActivity('yesterday - did some work'),
}));
const signals = detectSignals(dir);
assert.equal(signals.stale_activity, false,
'unusable last_activity must still resolve stale_activity: false (continuity is correct)');
});
test('unusable last_activity emits LAST_ACTIVITY_UNPARSEABLE diagnostic', () => {
_resetUnusableInputWarningsForTests();
const dir = track(makeProject({
state: makeStateWithActivity('not-a-date-at-all'),
}));
detectSignals(dir);
assert.equal(_unusableInputEmissionCountForTests(), 1,
'unusable last_activity must emit exactly one diagnostic');
});
test('absent last_activity emits nothing (distinguishable from unusable)', () => {
_resetUnusableInputWarningsForTests();
const dir = track(makeProject({
state: [
'---',
'status: executing',
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n'),
}));
detectSignals(dir);
assert.equal(_unusableInputEmissionCountForTests(), 0,
'absent last_activity must NOT emit a diagnostic (it is genuinely absent, not corrupt)');
});
test('well-formed last_activity emits nothing', () => {
_resetUnusableInputWarningsForTests();
const dir = track(makeProject({
state: makeStateWithActivity('2026-06-13T12:00:00Z'),
}));
detectSignals(dir);
assert.equal(_unusableInputEmissionCountForTests(), 0,
'well-formed last_activity must NOT emit a diagnostic');
});
test('unusable last_activity does not re-emit on second call (dedup)', () => {
_resetUnusableInputWarningsForTests();
const dir = track(makeProject({
state: makeStateWithActivity('gibberish'),
}));
detectSignals(dir);
detectSignals(dir);
assert.equal(_unusableInputEmissionCountForTests(), 1,
'dedup: second call on the same source must not re-emit');
});
});

View File

@@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => {
// (enum + call site + this assertion) instead of a silent widening.
assert.deepStrictEqual(
Object.keys(UNUSABLE_REASON).sort(),
['FRONTMATTER_UNTERMINATED', 'ROADMAP_UNREADABLE'],
['FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE'],
);
assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated');
});