Files
msd-core/tests/clock-seam.test.cjs
Tom Boucher e201cde73c refactor(#3186): one shared phase-completion predicate, disk-strict (#3306)
* docs(#3186): record the disk-strict completion decision in ADR-3180 7.4

The maintainer decided #2957 on 2026-08-08: disk state is authoritative and a
ROADMAP checkbox is a human annotation with no machine authority. Section 7.4
still carried the OPEN QUESTION and was marked blocked, so the contract said one
thing and the tracker another.

Recorded per section 7's own rule - a behavior not stated there is not decided,
and amending a rule is an ADR amendment rather than a code change with a comment.
The decision comment names Phase 4's PR as the carrier of this edit and makes it
an acceptance criterion that the text be in the tree before implementation
begins, so this lands first, alone, ahead of any code.

Also clears the stale blocked-on-2957 row in the guard roster.

* refactor(#3186): one shared phase-completion predicate, disk-strict

isPhaseComplete in verification.cts becomes the single owner. It calls
readVerificationStatus UNCONDITIONALLY - plan count is not a precondition - so a
zero-plan phase with a passing VERIFICATION.md is complete. That is #3168: init
gated the read on a plan count and synthesized a not_required sentinel, so
phase.complete succeeded while init.manager reported incomplete for the same
phase.

The guard, built and run before scope was fixed per Amendment 3, found 9
re-derivations where the ADR named 3. Four were unnamed, including one in the
prompt layer: mvp-phase.md ORed a ticked checkbox with disk status, which under
disk-strict is the divergence itself.

Per the #2957 decision, a ticked ROADMAP checkbox is a human annotation with no
machine authority. The overrides in roadmap analyze and init manager are deleted
rather than generalized; the user's checkbox stays in ROADMAP.md, only its
authority goes.

scanPhasePlans.completed and buildWorkstreamInventory are deliberately NOT folded
- they answer 'are all plans summarized', which is a different question, and
folding them would either over-report completion or invert the dependency
direction between Phase 1's owner and this one.

Verified on the remote runner.

* fix(#3186): close seven review findings and record the missing-verdict rule

The isolated review reproduced a write-path regression I introduced: migrating
cmdRoadmapUpdatePlanProgress dropped its summaryCount>=planCount gate, so a phase
with a fresh passing verification plus a newly-added unsummarized plan reported
complete AND wrote a checkbox into ROADMAP.md while phase complete refused. The
owner stays right per 7.4 - plan count is not a completion precondition - so the
gate is restored at the write site as an explicit composition, mirroring the
separate 2648 unexecuted-plan gate cmdPhaseComplete already carries.

The spec axis was right that my 0.x-split reasoning was too permissive. The 2957
decision names buildStateFrontmatter as one of the three that must converge, and
buildWorkstreamInventory combined a summaries-met local with verification data to
decide the same verdict - Decision 4(c)'s named bypass, and it reproduced 3168 in
a third surface. Both now route through the owner. The raw scanPhasePlans helper
stays: it answers are-plans-summarized, which genuinely is a different question.

Maintainer decision recorded in 7.4: a missing verdict is not a passing one, so
an absent VERIFICATION.md means not complete everywhere. That retires 2645's
verifier-disabled tolerance and inverts its Goodhart incentive - deleting the
evidence now lowers completion instead of raising it.

Guard hardened: block-form count gates and algebraic restatements are caught, and
the header now discloses its remaining limits instead of overclaiming.

Verified on the remote runner.

* fix(#3186): route state sync through the owner and catch bare completed reads

The matrix found 52 failures. 51 were fixtures asserting the old semantics: a
phase with plans and summaries but no VERIFICATION.md used to count complete and
correctly no longer does. Each fixture now carries a passing verification where
that is what the test was actually about, rather than having its assertion
weakened.

The 52nd was a real 10th re-derivation the guard could not see. cmdStateSync
destructured scanPhasePlans().completed directly - a bare field read, not a
comparison - and used it as a completion verdict, so state sync and state json
disagreed on completed_phases for identical disk state. Routed through the owner.

Guard gains shape (d): any read of .completed off a scanPhasePlans() result
outside plan-scan.cts, in chained, destructured and indirect forms, function
scoped with no line window. It cannot tell a summaries-met read from a completion
read - that is data flow - so it flags every one and requires a written-reason
exemption, which is the same discipline shapes a-c already use. The blind spot is
disclosed in the header rather than overclaimed.

The emitted-attribution failure was also mine, not pre-existing: the mvp-phase.md
checkbox-OR removal moves emitted bytes, acknowledged in tests/emitted-drift-acks.

Verified on the remote runner.

* test(#3186): give the nested-plans sync fixture a passing verification

Last 3 matrix failures were one failure echoing up two describe levels. Phase
01-alpha had plans and summaries but no VERIFICATION.md, so under disk-strict
completed stayed 0 and no Progress change was emitted - correct new behavior, not
a regression.

Added the passing verification rather than dropping the Progress expectation, so
the test still covers what #3257 is about: that a nested plans/ layout is counted
and not undercounted. Probe against the built lib confirms
Progress: 0% -> 50% alongside Total Plans in Phase: 0 -> 3.

* chore(#3186): backfill changeset PR number

pr:0 placeholder replaced with the real number now that #3306 exists.

---------

Co-authored-by: sim <sim@local>
2026-08-10 10:18:29 -04:00

1425 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// allow-test-rule: pending-migration-to-typed-ir [#3090]
// This file's assert.throws(fn, /regex/) sites (acquireStateLock /
// readModifyWriteStateMd / withPlanningLock / acquireInstallMigrationLock
// timeout and error-propagation checks) regex-match the human-readable
// thrown Error#message — CONTRIBUTING's "Error / status / reason" BAD
// pattern; the fix is a frozen-enum REASON code on the thrown error, which
// requires a production change to those locking functions that is out of
// scope for this test-only change. Tracked for correction under #3090.
/**
* Deterministic clock-seam tests for acquireStateLock / withPlanningLock (issue #453).
*
* Replaces the timing-dependent tests identified in the #453 research:
*
* locking-bugs:63 — source-grep for Atomics.wait → in-process fake-clock proof
* locking-bugs:130 — source-grep for process.on('exit') in state.cjs → exit-cleanup integration test
* locking-bugs:143 — source-grep for process.on('exit') in planning-workspace.cjs → idem
* locking-bugs:467 — source-grep asserting all 9 cmd* functions call readModifyWriteStateMd →
* replaced by DI-based unit test confirming each cmd* goes through the seam
* locking-bugs:647 — source-grep asserting config.cjs uses withPlanningLock →
* replaced by the functional barrier-based test at locking-bugs:545 (CONVERT kept)
*
* concurrency-safety:521 — 100-line normalizeMd perf wall-clock → no timing replacement needed;
* snapshot tests in concurrency-safety already cover correctness
* concurrency-safety:548 — 1000-line normalizeMd perf wall-clock → same
* concurrency-safety:794 — roadmap analyze elapsed < 5000ms → replaced by behavioral test below
*
* New deterministic coverage added here:
* 1. Fake-clock proof that acquireStateLock uses clock.now() and clock.sleep()
* 2. Timeout throw at maxWaitMs boundary (driven by fake clock advance)
* 3. Stale-lock takeover when mtime difference exceeds staleThresholdMs
* 4. Lock released on error path (finally branch in readModifyWriteStateMd)
* 5. withPlanningLock timeout fires when fake clock exceeds lockTimeout
* 6. Roadmap analyze behavioral assertion (50 phases, correctness) without timing gate
* 7. Exit-cleanup integration: lock file absent after process holding it exits
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { makeFakeClock } = require('./helpers/clock.cjs');
const stateMod = require('../gsd-core/bin/lib/state.cjs');
const { acquireStateLock, releaseStateLock, readModifyWriteStateMd } = stateMod;
const { withPlanningLock } = require('../gsd-core/bin/lib/planning-workspace.cjs');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// 1. Fake-clock proof: acquireStateLock accepts and uses the clock seam
// ─────────────────────────────────────────────────────────────────────────────
describe('acquireStateLock clock seam', () => {
let tmpDir;
let statePath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n');
});
afterEach(() => {
// Remove any leftover lock
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
cleanup(tmpDir);
});
test('lock acquired immediately when no contention — clock.now() invoked at startup', () => {
const clock = makeFakeClock(1000);
const lockPath = acquireStateLock(statePath, clock);
assert.ok(fs.existsSync(lockPath), 'lock file must exist after acquire');
assert.ok(clock.sleepCalls.length === 0, 'no sleep should occur when lock is immediately available');
releaseStateLock(lockPath);
assert.ok(!fs.existsSync(lockPath), 'lock file must be removed after release');
});
test('clock.sleep() called when lock is held — sleep count matches retry count', () => {
const clock = makeFakeClock(0);
// Pre-create the lock file to simulate a held lock
const lockPath = statePath + '.lock';
fs.writeFileSync(lockPath, String(process.pid));
// The lock is held by a live PID (our own process.pid).
// acquireStateLock will retry. We need the clock to advance past maxWaitMs
// on each sleep call so the timeout fires after the first retry.
//
// Override sleep to advance time beyond 30 000 ms on first call so the
// timeout check on the NEXT iteration throws immediately.
const fastClock = {
now: clock.now.bind(clock),
sleep(ms) {
clock.sleep(ms);
// After each sleep, jump past the 30 000 ms budget
clock.advance(31000);
},
};
assert.throws(
() => acquireStateLock(statePath, fastClock),
/acquireStateLock.*exceeded.*30000ms budget/,
'must throw timeout error when maxWaitMs is exceeded'
);
// Remove the lock file (we placed it ourselves)
fs.unlinkSync(lockPath);
});
test('stale lock is removed and acquisition succeeds when mtime exceeds staleThresholdMs', () => {
const lockPath = statePath + '.lock';
fs.writeFileSync(lockPath, '99999'); // non-existent PID
// Back-date mtime by 11 000 ms (> staleThresholdMs of 10 000 ms)
const staleMs = 11000;
const staledTime = new Date(Date.now() - staleMs);
fs.utimesSync(lockPath, staledTime, staledTime);
// Use a fake clock that starts at a time such that:
// clock.now() - stat.mtimeMs > 10 000
// The stat.mtimeMs is real (just backdated), so we need clock.now() to
// return a value > staledTime.getTime() + 10000.
const clock = makeFakeClock(Date.now() + 100); // well past the stale threshold
const acquired = acquireStateLock(statePath, clock);
assert.ok(fs.existsSync(acquired), 'must acquire lock after taking over stale lock');
releaseStateLock(acquired);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 1a. acquireStateLock PID-liveness staleness (audit M1)
//
// mtime is a leaky proxy for "holder is alive": a live-but-slow holder whose
// critical section runs past staleThresholdMs ages out and gets its lock stolen
// by a waiter → two writers in STATE.md's critical section → lost update.
// The fix gates the steal on a real liveness signal (process.kill(pid,0),
// injected via the _setLockProbes seam) and orders the deadman ceiling ABOVE the
// wait budget so a verified-live holder is NEVER stolen within budget. A dead
// holder is stolen promptly regardless of age. A garbage/legacy body is treated
// as not-verified-live so corrupt locks stay recoverable under the deadman ceiling.
// ─────────────────────────────────────────────────────────────────────────────
describe('acquireStateLock PID-liveness staleness (audit M1)', () => {
let tmpDir;
let statePath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-liveness-state-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n');
});
afterEach(() => {
stateMod._resetLockProbes();
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
cleanup(tmpDir);
});
test('exports _setLockProbes / _resetLockProbes seams', () => {
assert.ok(typeof stateMod._setLockProbes === 'function', '_setLockProbes seam must be exported');
assert.ok(typeof stateMod._resetLockProbes === 'function', '_resetLockProbes seam must be exported');
});
test('live holder is NOT stolen even when aged past the stale threshold (waiter budgets out)', () => {
const lockPath = statePath + '.lock';
const livePid = 4242;
fs.writeFileSync(lockPath, String(livePid));
// Holder pid reads as ALIVE via the injected probe (deterministic, no real pid).
stateMod._setLockProbes({ isPidAlive: (pid) => pid === livePid });
// Drive the clock so the lock is aged WELL past the 10 000 ms stale threshold
// (stale < age) but the waiter only ever budgets out at maxWaitMs (30 000 ms).
// sleep advances time; once the 30 000 ms budget is exhausted it must throw,
// and it must NOT have unlinked the live holder's lock.
const clock = makeFakeClock(60000); // age = now - mtime ≫ 10 000 ms
assert.throws(
() => acquireStateLock(statePath, clock),
/acquireStateLock.*exceeded.*30000ms budget/,
'a verified-live holder must never be stolen within the wait budget — waiter must time out instead'
);
// The live holder's lock body must be intact (never unlinked + re-created).
assert.ok(fs.existsSync(lockPath), 'live holder lock must still exist (not stolen)');
assert.strictEqual(fs.readFileSync(lockPath, 'utf-8'), String(livePid), 'live holder lock body must be unchanged');
fs.unlinkSync(lockPath);
});
test('dead holder is stolen promptly without waiting out the full budget', () => {
const lockPath = statePath + '.lock';
const deadPid = 777;
fs.writeFileSync(lockPath, String(deadPid));
// Holder pid reads as DEAD via the injected probe → eligible for immediate steal.
stateMod._setLockProbes({ isPidAlive: () => false });
// Fresh, NON-aged lock (mtime ≈ now). Without liveness the old mtime-only gate
// would refuse to steal a <10 000 ms lock and force a long wait; with liveness
// a dead holder is stolen immediately regardless of age.
const clock = makeFakeClock(Date.now());
const acquired = acquireStateLock(statePath, clock);
assert.ok(fs.existsSync(acquired), 'dead holder lock must be stolen and re-acquired');
assert.strictEqual(
clock.sleepCalls.length, 0,
'a dead holder must be stolen promptly — no wait/backoff sleeps before acquisition'
);
releaseStateLock(acquired);
});
test('garbage/legacy lock body → not-verified-live → recoverable under the deadman ceiling, never an infinite block', () => {
const lockPath = statePath + '.lock';
fs.writeFileSync(lockPath, 'not-a-pid\x00garbage'); // unreadable / non-numeric body
// Probe would say "alive" for ANY pid — proves the steal does not depend on a
// bogus parse succeeding: an unparseable body is treated as not-verified-live.
stateMod._setLockProbes({ isPidAlive: () => true });
// Age the body past the deadman ceiling (above maxWaitMs) so the corrupt lock
// is recoverable rather than blocking forever.
const clock = makeFakeClock(Date.now() + 120000);
const acquired = acquireStateLock(statePath, clock);
assert.ok(fs.existsSync(acquired), 'corrupt/legacy lock must be recoverable (stolen under the deadman ceiling)');
releaseStateLock(acquired);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 1c. Steal-safety windows (PR #1532 review — trek-e)
//
// The PID-liveness backport (audit M1) dropped two pieces of capability-lock.cts's
// race-free steal machinery, reopening the #500/#905/#1230 lost-update family:
//
// (a) Empty-body create window — acquireStateLock creates the lock with O_EXCL and
// writes the pid in a SEPARATE writeSync. A lock observed in that window has an
// EMPTY body → _stateHolderVerifiedLive('') is false → the no-floor steal gate
// robs it at age ≈ 0, mid-creation. capability-lock never steals a FRESH lock
// (age <= LOCK_STALE_MS) regardless of body, which is what protects that window.
//
// (b) Double-steal — the steal is a bare fs.unlinkSync with no identity re-confirm
// between the decision and the unlink. A racer that steals + recreates a fresh
// lock in that gap has its replacement deleted by the first stealer's unlink →
// two concurrent holders. capability-lock re-confirms (dev,ino) immediately
// before an ATOMIC rename-steal so only one racer can win.
//
// Both are driven deterministically through the lock seams (clock + pid probe +
// onLoopIteration + beforeSteal) — no wall-clock, no real concurrency.
// ─────────────────────────────────────────────────────────────────────────────
describe('acquireStateLock steal-safety windows (PR #1532)', () => {
let tmpDir;
let statePath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stealsafety-state-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n');
});
afterEach(() => {
stateMod._resetLockProbes();
stateMod._resetStateLockTestHooks();
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
cleanup(tmpDir);
});
test('a FRESH empty-body lock (mid-creation) is NOT stolen at age ~0 — acquirer backs off', () => {
const lockPath = statePath + '.lock';
// Simulate the create→pid-write window of a CONCURRENT acquirer: the lockfile
// exists (O_EXCL create succeeded) but the pid has not been written yet → empty body.
fs.writeFileSync(lockPath, '');
const freshTime = new Date();
fs.utimesSync(lockPath, freshTime, freshTime); // mtime ≈ now → age ≈ 0 (fresh)
// The body is empty, so liveness cannot be determined from it — the probe value is
// irrelevant. The (buggy) no-floor gate steals it regardless; the fix must wait.
stateMod._setLockProbes({ isPidAlive: () => false });
// After the first encounter, clear the empty lock so the (correctly-waiting) acquirer
// can complete instead of budgeting out — keeps the test bounded and the assertion
// about the FIRST decision, not the eventual outcome.
stateMod._setStateLockTestHooks({
onLoopIteration: ({ iteration }) => {
if (iteration >= 1) { try { fs.unlinkSync(lockPath); } catch { /* already gone */ } }
},
});
const clock = makeFakeClock(freshTime.getTime());
const acquired = acquireStateLock(statePath, clock);
assert.ok(fs.existsSync(acquired), 'lock must eventually be acquired');
assert.ok(
clock.sleepCalls.length >= 1,
'a fresh empty-body lock is mid-creation and must NOT be stolen at age ~0 — ' +
'the acquirer must back off (sleep) at least once, not unlink + steal immediately'
);
releaseStateLock(acquired);
});
test('a dead holder whose lock is recreated by a racer mid-steal is NOT double-stolen (identity re-confirm)', () => {
const lockPath = statePath + '.lock';
const deadPid = 4040;
const livePid = 5050;
// Decision-time holder: a DEAD pid → eligible for steal.
fs.writeFileSync(lockPath, String(deadPid));
const t = new Date();
fs.utimesSync(lockPath, t, t);
stateMod._setLockProbes({ isPidAlive: (pid) => pid === livePid });
// Inject a concurrent waiter that, in the gap between our steal-DECISION and our
// steal, already stole + recreated a FRESH lock owned by a LIVE pid. A correct
// (identity-re-confirming) acquirer must notice the lock instance changed and must
// NOT delete the racer's live replacement.
let injected = false;
stateMod._setStateLockTestHooks({
beforeSteal: () => {
if (injected) return;
injected = true;
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
fs.writeFileSync(lockPath, String(livePid)); // different identity + live holder
const f = new Date();
fs.utimesSync(lockPath, f, f);
},
});
const clock = makeFakeClock(t.getTime());
// The racer's replacement is held by a LIVE pid → the acquirer must wait on it and
// budget out rather than stealing it. (A double-steal would instead delete it and
// succeed.)
assert.throws(
() => acquireStateLock(statePath, clock),
(err) => err && err.lockBudgetExceeded === true,
'acquirer must not double-steal the racer\'s live replacement — it must wait + budget out'
);
assert.strictEqual(
fs.readFileSync(lockPath, 'utf-8'), String(livePid),
'the racer\'s freshly-recreated live lock must survive — never deleted by a stale-decision unlink'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 1b. Regression #1217 — acquireStateLock ENOENT (recoverable errno) busy-spin
//
// Prior to the fix the recoverable-errno branch (`continue`) never called
// clock.sleep() or checked the budget, so a permanently-failing ENOENT from
// a deleted parent dir spun at 100% CPU forever. With the fix every retry
// path must (a) advance the clock via sleep() and (b) throw when the 30 000 ms
// budget is exhausted.
// ─────────────────────────────────────────────────────────────────────────────
describe('acquireStateLock recoverable errno budget + backoff (#1217)', () => {
let tmpDir;
let statePath;
let origOpenSync;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-1217-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n');
origOpenSync = fs.openSync;
});
afterEach(() => {
// Restore openSync if a test patched it
fs.openSync = origOpenSync;
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
cleanup(tmpDir);
});
test('persistent ENOENT throws budget-exceeded error (not busy-spin) — clock must advance via sleep', () => {
// Arrange: always-ENOENT openSync (parent dir permanently gone scenario)
const enoentErr = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
fs.openSync = () => { throw enoentErr; };
const clock = makeFakeClock(0);
// Act + Assert: must throw (not hang) with budget-exceeded message
assert.throws(
() => acquireStateLock(statePath, clock),
/acquireStateLock.*exceeded.*30000ms budget/,
'must throw budget-exceeded error when ENOENT persists beyond maxWaitMs'
);
// The clock must have advanced by at least maxWaitMs (30 000 ms).
// Before the fix: no sleep() ever called → nowValue stays at 0 → spins forever.
// After the fix: every retry sleeps → nowValue ≥ 30 000 ms → budget throws.
assert.ok(
clock.nowValue >= 30000,
`clock must have advanced ≥ 30 000 ms via sleep() calls (got ${clock.nowValue}ms); a value of 0 means the errno branch never slept (busy-spin)`
);
// At least one sleep call must have been recorded
assert.ok(
clock.sleepCalls.length >= 1,
`sleep must be called at least once on recoverable errno retry (got ${clock.sleepCalls.length} calls)`
);
});
test('transient ENOENT (a few retries then success) acquires lock normally', () => {
// Arrange: fail twice with ENOENT, then succeed
const enoentErr = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
let callCount = 0;
fs.openSync = (...args) => {
callCount++;
if (callCount <= 2) throw enoentErr;
// Restore and delegate to real openSync for the successful attempt
fs.openSync = origOpenSync;
return origOpenSync.apply(fs, args);
};
const clock = makeFakeClock(0);
// Act: should succeed (not throw) because ENOENT was transient
const lockPath = acquireStateLock(statePath, clock);
// Assert: lock file exists
assert.ok(fs.existsSync(lockPath), 'lock must be acquired after transient ENOENT retries');
// 2 retries → at least 2 sleep calls
assert.ok(clock.sleepCalls.length >= 2, `expected ≥2 sleep calls for 2 ENOENT retries, got ${clock.sleepCalls.length}`);
releaseStateLock(lockPath);
assert.ok(!fs.existsSync(lockPath), 'lock must be released after releaseStateLock');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 1c. Boundary coverage + backoff-range for the recoverable-errno retry path
//
// RULESET.TESTS.boundary-coverage: inputs at limit-1, limit, and limit+1
// for the 30 000 ms maxWaitMs budget.
//
// Each sleep advances the clock by retryDelay (200 ms) + exactly 0 jitter
// (achieved via a deterministic-jitter clock wrapper). We then control how
// much additional time to add so the budget check lands at the desired point.
//
// Scenario A — budget NOT yet exhausted: error clears just before limit
// Scenario B — budget exactly at limit (>= check): must throw
// Scenario C — budget over limit: must throw immediately
// ─────────────────────────────────────────────────────────────────────────────
describe('acquireStateLock boundary coverage — recoverable-errno budget (#1217)', () => {
let tmpDir;
let statePath;
let origOpenSync;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-boundary-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n');
origOpenSync = fs.openSync;
});
afterEach(() => {
fs.openSync = origOpenSync;
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
cleanup(tmpDir);
});
/**
* Build a fake clock where every sleep() advances time by exactly fixedSleepMs
* regardless of the requested delay. This gives us deterministic elapsed-time
* sequences without depending on Math.random() jitter.
*/
function makeFixedSleepClock(startMs, fixedSleepMs) {
let _now = startMs;
const _sleepCalls = [];
return {
now() { return _now; },
sleep(ms) {
// Record the actual ms value requested by the production code (for range checks)
// but advance by fixedSleepMs so total elapsed is predictable.
_sleepCalls.push(ms);
_now += fixedSleepMs;
},
get sleepCalls() { return _sleepCalls; },
get nowValue() { return _now; },
};
}
test('backoff-range contract: every sleep call is in [retryDelay, retryDelay + jitterMax) range', () => {
// Scenario: persistent ENOENT for 3 retries then succeed.
// retryDelay=200, jitter ∈ [0,49] → sleep value must be in [200, 249].
const enoentErr = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
let calls = 0;
fs.openSync = (...args) => {
calls++;
if (calls <= 3) throw enoentErr;
fs.openSync = origOpenSync;
return origOpenSync.apply(fs, args);
};
// Use real makeFakeClock (sleep advances by requested ms, so time moves at 200-249ms per sleep)
const clock = makeFakeClock(0);
const lockPath = acquireStateLock(statePath, clock);
releaseStateLock(lockPath);
assert.strictEqual(clock.sleepCalls.length, 3, 'must have exactly 3 sleep calls for 3 ENOENT retries');
for (let i = 0; i < clock.sleepCalls.length; i++) {
const delayMs = clock.sleepCalls[i];
assert.ok(
delayMs >= 200 && delayMs <= 249,
`sleep[${i}] = ${delayMs}ms must be in [200, 249] (retryDelay=200 + jitter 0..49)`
);
}
});
test('budget just UNDER limit: error clears at 29 999 ms elapsed — lock acquired, no throw', () => {
// Arrange: openSync fails with ENOENT for 30 iterations, then succeeds.
// Sleeps 1-29 each advance 1000 ms (total 29 000 ms after 29 sleeps).
// Sleep 30 advances only 999 ms (total 29 999 ms) — still under the 30 000 ms budget.
// openSync succeeds on the 31st attempt BEFORE elapsed reaches 30 000 ms.
const enoentErr = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
let calls = 0;
const maxFails = 30; // 30 ENOENT failures → 30 sleeps → final elapsed = 29 999 ms
fs.openSync = (...args) => {
calls++;
if (calls <= maxFails) throw enoentErr;
fs.openSync = origOpenSync;
return origOpenSync.apply(fs, args);
};
// Custom clock: first 29 sleeps advance 1000 ms each; the 30th advances 999 ms.
// Total elapsed at success = 29 × 1000 + 1 × 999 = 29 999 ms (< 30 000 ms).
let _now = 0;
const _sleepCalls = [];
const clock = {
now() { return _now; },
sleep(ms) {
_sleepCalls.push(ms);
// The 30th sleep advances by 999 ms; all others advance by 1000 ms.
_now += _sleepCalls.length === 30 ? 999 : 1000;
},
get sleepCalls() { return _sleepCalls; },
get nowValue() { return _now; },
};
// Should NOT throw — budget not yet exhausted at 29 999 ms
const lockPath = acquireStateLock(statePath, clock);
assert.ok(fs.existsSync(lockPath), 'lock must be acquired when error clears before budget');
releaseStateLock(lockPath);
assert.strictEqual(clock.sleepCalls.length, maxFails, `expected ${maxFails} sleep calls`);
assert.strictEqual(clock.nowValue, 29999, `elapsed must be exactly 29 999 ms at success (got ${clock.nowValue}ms)`);
});
test('budget AT limit (elapsed === 30 000 ms): must throw budget-exceeded error', () => {
// Arrange: openSync always fails — budget is hit exactly at 30 000 ms.
// fixedSleepMs=1000, after 30 sleeps elapsed=30000 → >= check fires.
const enoentErr = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
fs.openSync = () => { throw enoentErr; };
const clock = makeFixedSleepClock(0, 1000);
assert.throws(
() => acquireStateLock(statePath, clock),
/acquireStateLock.*exceeded.*30000ms budget/,
'must throw budget-exceeded when elapsed equals maxWaitMs'
);
// After 30 sleeps (30 × 1000 = 30 000 ms) the budget fires
assert.ok(clock.nowValue >= 30000, `clock must be at or past 30 000 ms (got ${clock.nowValue}ms)`);
});
test('budget OVER limit (elapsed > 30 000 ms): must throw immediately', () => {
// Arrange: openSync always fails.
// Use a clock that starts already past the budget so the first budget check
// on the SECOND iteration fires immediately (after one sleep).
const enoentErr = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
fs.openSync = () => { throw enoentErr; };
// fixedSleepMs=35000 — one sleep puts elapsed at 35000 > 30000
const clock = makeFixedSleepClock(0, 35000);
assert.throws(
() => acquireStateLock(statePath, clock),
/acquireStateLock.*exceeded.*30000ms budget/,
'must throw budget-exceeded when elapsed exceeds maxWaitMs'
);
// Only one sleep call needed to exceed the budget
assert.strictEqual(clock.sleepCalls.length, 1, 'budget must fire after a single over-budget sleep');
assert.ok(clock.nowValue > 30000, `clock must be past 30 000 ms (got ${clock.nowValue}ms)`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 1d. Regression #1217 — statSync and unlinkSync spin paths in stale-lock branch
//
// Prior to the fix in this PR, two paths in the EEXIST handler were unbounded:
// • persistent fs.statSync failure → catch { continue; } (no sleep, no budget)
// • persistent fs.unlinkSync failure → catch swallowed, then continue (no sleep, no budget)
// Both would spin at 100% CPU forever. After the fix, both call checkBudgetAndSleep
// before continuing, so they throw within maxWaitMs.
// ─────────────────────────────────────────────────────────────────────────────
describe('acquireStateLock statSync/steal spin paths bounded (#1217)', () => {
let tmpDir;
let statePath;
let origStatSync;
let origUnlinkSync;
let origRenameSync;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-spin-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n');
origStatSync = fs.statSync;
origUnlinkSync = fs.unlinkSync;
origRenameSync = fs.renameSync;
// Force the recorded holder (pid 99999) DEAD so the steal path is exercised
// deterministically — these tests probe the steal's bounded-backoff, not liveness.
stateMod._setLockProbes({ isPidAlive: () => false });
});
afterEach(() => {
fs.statSync = origStatSync;
fs.unlinkSync = origUnlinkSync;
fs.renameSync = origRenameSync;
stateMod._resetLockProbes();
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
cleanup(tmpDir);
});
test('persistent statSync failure throws budget-exceeded (not busy-spin)', () => {
// Set up EEXIST condition: pre-create lock file so openSync hits EEXIST
const lockPath = statePath + '.lock';
fs.writeFileSync(lockPath, '99999'); // non-current PID
// Make statSync always throw a transient error (e.g. NFS hiccup)
const statErr = Object.assign(new Error('EIO: I/O error'), { code: 'EIO' });
fs.statSync = (p) => {
if (p === lockPath) throw statErr;
return origStatSync(p);
};
// Use a fixed-sleep clock so the budget is hit predictably
let _now = 0;
const sleepCalls = [];
const clock = {
now() { return _now; },
sleep(ms) { sleepCalls.push(ms); _now += 1000; }, // advance 1000ms each sleep
};
assert.throws(
() => acquireStateLock(statePath, clock),
/acquireStateLock.*exceeded.*30000ms budget/,
'persistent statSync failure must throw budget-exceeded, not spin forever'
);
// Must have slept at least once (not a busy-spin)
assert.ok(sleepCalls.length >= 1, `sleep must have been called at least once (got ${sleepCalls.length}); zero means busy-spin`);
assert.ok(_now >= 30000, `clock must be at or past 30 000 ms after exhausting budget (got ${_now}ms)`);
// Clean up patched lock
fs.unlinkSync = origUnlinkSync;
try { origUnlinkSync(lockPath); } catch { /* ok */ }
});
test('persistent renameSync failure in steal path throws budget-exceeded (not busy-spin)', () => {
// Set up an EEXIST condition with a steal-eligible DEAD holder (pid 99999 — not us,
// not alive). The steal is an ATOMIC rename (PR #1532); a persistent rename failure
// (e.g. EPERM — file locked by an AV scanner) must back off + budget out, not spin.
const lockPath = statePath + '.lock';
fs.writeFileSync(lockPath, '99999');
// Make renameSync always fail for the steal of our lock path.
const renameErr = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
fs.renameSync = (from, to) => {
if (from === lockPath) throw renameErr;
return origRenameSync(from, to);
};
// Clock where now() returns current real time so the steal branch fires,
// but sleep advances a fixed 1000ms per call so budget is hit deterministically.
const realNow = Date.now();
let _elapsed = 0;
const sleepCalls = [];
const clock = {
now() { return realNow + _elapsed; },
sleep(ms) { sleepCalls.push(ms); _elapsed += 1000; },
};
assert.throws(
() => acquireStateLock(statePath, clock),
/acquireStateLock.*exceeded.*30000ms budget/,
'persistent renameSync failure in steal path must throw budget-exceeded, not spin forever'
);
assert.ok(sleepCalls.length >= 1, `sleep must have been called at least once (got ${sleepCalls.length}); zero means busy-spin`);
assert.ok(_elapsed >= 30000, `elapsed must reach 30 000 ms budget (got ${_elapsed}ms)`);
// Restore renameSync for cleanup
fs.renameSync = origRenameSync;
try { origUnlinkSync(lockPath); } catch { /* ok */ }
});
test('persistent renameSync failure error message names steal cause, not statSync (#1217 diagnostic)', () => {
// Regression guard for the misleading-error-context bug: when the steal's renameSync
// fails and checkBudgetAndSleep throws at the budget boundary, the outer statSync
// catch must NOT re-wrap it with "statSync failed after EEXIST". The thrown error
// must name the real cause ("stale lock steal lost to racer") so operators can
// identify it.
const lockPath = statePath + '.lock';
fs.writeFileSync(lockPath, '99999');
// renameSync always fails — the budget will be exhausted on the first sleep.
const renameErr = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
fs.renameSync = (from, to) => {
if (from === lockPath) throw renameErr;
return origRenameSync(from, to);
};
const realNow = Date.now();
let _elapsed = 0;
const clock = {
now() { return realNow + _elapsed; },
sleep(_ms) { _elapsed += 35000; }, // jump past the 30 000 ms budget on first sleep
};
let thrownErr;
try {
acquireStateLock(statePath, clock);
} catch (e) {
thrownErr = e;
}
assert.ok(thrownErr, 'must throw when renameSync persistently fails and budget is exhausted');
assert.ok(
/stale lock steal lost to racer/.test(thrownErr.message),
`error message must contain "stale lock steal lost to racer" (got: ${thrownErr.message})`
);
assert.ok(
!/statSync failed after EEXIST/.test(thrownErr.message),
`error message must NOT contain "statSync failed after EEXIST" (the misleading re-wrap) (got: ${thrownErr.message})`
);
fs.renameSync = origRenameSync;
try { origUnlinkSync(lockPath); } catch { /* ok */ }
});
test('successful stale-lock steal acquires immediately even when budget is already exhausted — no throw (#1217 regression)', () => {
// Regression guard: the OLD code called checkBudgetAndSleep() unconditionally
// after fs.unlinkSync, so a successful steal when elapsed >= maxWaitMs would
// throw budget-exceeded even though the lock was already freed. The fix lets
// a successful steal `continue` immediately without a budget check.
//
// Arrange: stale lock with mtime well in the past.
const lockPath = statePath + '.lock';
fs.writeFileSync(lockPath, '99999');
const staleMs = 20000;
const staledTime = new Date(Date.now() - staleMs);
fs.utimesSync(lockPath, staledTime, staledTime);
// Clock: now() returns a time that is (a) past the stale threshold AND
// (b) already >= maxWaitMs ahead of startedAt. The stale branch fires,
// unlinkSync SUCCEEDS (we do NOT patch it), and with the fix the lock is
// immediately acquired — budget-exceeded must NOT be thrown.
const realNow = Date.now();
// startedAt = realNow; clock.now() on first call = realNow (startedAt captured).
// After the stale branch unlinks, clock.now() is still realNow → elapsed = 0 < 30000.
// To prove the regression, advance the clock so that elapsed is past the budget
// at the moment the budget check WOULD have fired (i.e. > 30000 ms ahead of startedAt).
// We use a clock where now() starts at 0 (for startedAt) then jumps to 30001 after
// the first call, simulating 30001 ms having passed when the stale lock is found.
let nowCallCount = 0;
const sleepCalls = [];
const clock = {
now() {
nowCallCount++;
// First call (captured as startedAt) returns 0.
// All subsequent calls return 30001 — so elapsed = 30001 >= 30000.
// The stale check: clock.now() - stat.mtimeMs = 30001 - (realNow - staleMs).
// We need that to be > staleThresholdMs (10000). realNow - (realNow-staleMs) = staleMs=20000 > 10000 ✓
// But we need the mtime in absolute terms to make the stale check fire.
// Use realNow-based absolute clock: startedAt=realNow, elapsed on 2nd call=30001ms.
return nowCallCount === 1 ? realNow : realNow + 30001;
},
sleep(ms) { sleepCalls.push(ms); },
};
// Should NOT throw — successful steal must continue immediately even at elapsed > maxWaitMs.
const acquired = acquireStateLock(statePath, clock);
assert.ok(fs.existsSync(acquired), 'lock must be acquired after successful stale-lock steal');
// No sleep calls: the steal succeeded, so the fast-path `continue` was taken.
assert.strictEqual(sleepCalls.length, 0, 'no sleep should occur on a successful stale-lock steal');
releaseStateLock(acquired);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. readModifyWriteStateMd — lock released on error path
// ─────────────────────────────────────────────────────────────────────────────
describe('readModifyWriteStateMd lock cleanup on error', () => {
let tmpDir;
let statePath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n\n**Status:** Planning\n');
});
afterEach(() => {
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
cleanup(tmpDir);
});
test('lock file absent after transformFn throws', () => {
const clock = makeFakeClock(0);
assert.throws(
() => readModifyWriteStateMd(statePath, () => { throw new Error('intentional transform error'); }, tmpDir, undefined, clock),
/intentional transform error/,
'error from transformFn must propagate'
);
assert.ok(!fs.existsSync(statePath + '.lock'), 'lock must be released even when transformFn throws');
});
test('clock seam is passed through — no real sleep on immediate acquisition', () => {
const clock = makeFakeClock(0);
readModifyWriteStateMd(statePath, (c) => c + '\n**Patched:** yes\n', tmpDir, undefined, clock);
const content = fs.readFileSync(statePath, 'utf-8');
assert.strictEqual(stateExtractField(content, 'Patched'), 'yes', 'transform must be applied');
assert.strictEqual(clock.sleepCalls.length, 0, 'no sleep when lock is immediately available');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. withPlanningLock clock seam
// ─────────────────────────────────────────────────────────────────────────────
describe('withPlanningLock clock seam', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-planning-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
});
afterEach(() => {
try { fs.unlinkSync(path.join(tmpDir, '.planning', '.lock')); } catch { /* ok */ }
cleanup(tmpDir);
});
test('fn() return value is propagated when lock is available', () => {
const clock = makeFakeClock(0);
const result = withPlanningLock(tmpDir, () => 'hello from lock', clock);
assert.strictEqual(result, 'hello from lock');
assert.strictEqual(clock.sleepCalls.length, 0, 'no sleep when lock immediately available');
});
test('lock file absent after fn() completes', () => {
const clock = makeFakeClock(0);
withPlanningLock(tmpDir, () => {}, clock);
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', '.lock')), 'lock must be released after fn()');
});
test('lock file absent after fn() throws', () => {
const clock = makeFakeClock(0);
assert.throws(
() => withPlanningLock(tmpDir, () => { throw new Error('fn threw'); }, clock),
/fn threw/
);
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', '.lock')), 'lock must be released even when fn() throws');
});
test('timeout fires (sleep seam exercised) when a LIVE holder is contended past lockTimeout', () => {
// Audit M1 rewrite: the prior version asserted the now-REMOVED force-steal
// fallback (timeout → unconditional unlink + re-acquire). That fallback robbed
// live writers; the fix replaces it with a clear timeout throw. This test now
// pins the new contract: a verified-LIVE holder held past lockTimeout makes the
// waiter exercise the clock.sleep seam and then throw — never force-stolen.
const lockPath = path.join(tmpDir, '.planning', '.lock');
const livePid = 9191;
fs.writeFileSync(lockPath, JSON.stringify({ pid: livePid, cwd: tmpDir, acquired: new Date().toISOString() }));
// Holder reads as ALIVE via the injected probe → waited on, never stolen.
require('../gsd-core/bin/lib/planning-workspace.cjs')._setLockProbes({ isPidAlive: (pid) => pid === livePid });
let nowValue = 0;
const clock2 = {
now() { return nowValue; },
sleep(ms) { nowValue += ms + 11000; }, // advance past lockTimeout on first sleep
};
try {
assert.throws(
() => withPlanningLock(tmpDir, () => 'should-not-run', clock2),
/exceeded.*10000ms budget/,
'a live holder held past lockTimeout must throw a clear timeout error (not force-steal)'
);
// The sleep seam must have been exercised (timeout path reached).
assert.ok(nowValue > 10000, 'clock must have advanced past lockTimeout via the sleep seam');
// The live holder's lock must be intact (never unlinked).
assert.ok(fs.existsSync(lockPath), 'live holder lock must survive the timeout (not force-stolen)');
} finally {
require('../gsd-core/bin/lib/planning-workspace.cjs')._resetLockProbes();
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Exit-cleanup integration: lock absent after command that holds STATE.md.lock exits
// Replaces locking-bugs:130 (source-grep for process.on('exit') in state.cjs)
// ─────────────────────────────────────────────────────────────────────────────
describe('exit cleanup: STATE.md.lock removed on process exit', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE.md.lock absent after successful state command', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n\n**Status:** Planning\n**Current Phase:** 01\n');
runGsdTools('state update Status "In progress"', tmpDir);
assert.ok(
!fs.existsSync(statePath + '.lock'),
'STATE.md.lock must not persist after state command exits'
);
});
test('STATE.md.lock absent even when command exits non-zero', () => {
// Trigger a failing invocation (invalid field syntax) — the lock must still be released.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, '# State\n\n**Status:** Planning\n');
// run and ignore result — we only care about the lock file
runGsdTools('state update Status "In progress"', tmpDir);
assert.ok(
!fs.existsSync(statePath + '.lock'),
'STATE.md.lock must not persist regardless of command exit code'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Exit-cleanup integration: .planning/.lock removed on process exit
// Replaces locking-bugs:143 (source-grep for process.on('exit') in planning-workspace.cjs)
// ─────────────────────────────────────────────────────────────────────────────
describe('exit cleanup: .planning/.lock removed on process exit', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('.planning/.lock absent after phase add completes', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
);
runGsdTools('phase add Testing', tmpDir);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', '.lock')),
'.planning/.lock must not persist after phase add exits'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. readModifyWriteStateMd call-site coverage
// Replaces locking-bugs:467 (source-grep audit of 9 cmd* functions)
// Uses CLI-level integration: each cmd* is exercised through gsd-tools and
// the lock-cleanup assertion confirms readModifyWriteStateMd was called
// (the lock is only left clean by readModifyWriteStateMd's finally block).
// ─────────────────────────────────────────────────────────────────────────────
describe('readModifyWriteStateMd call-site coverage', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Current Phase Name:** Foundation',
'**Status:** In progress',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working',
'',
'### Decisions',
'None yet.',
'',
'### Blockers',
'None.',
'',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n- [ ] Phase 1: Foundation\n\n### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans\n\n### Phase 2: API\n**Goal:** Build\n'
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
});
afterEach(() => {
try { fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md.lock')); } catch { /* ok */ }
cleanup(tmpDir);
});
function assertNoLockFile() {
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock must be absent after command (confirms readModifyWriteStateMd cleaned up)');
}
test('cmdStateUpdate releases lock (state update)', () => {
runGsdTools('state update Status "Executing"', tmpDir);
assertNoLockFile();
});
test('cmdStateAdvancePlan releases lock (state advance-plan)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n'
);
runGsdTools('state advance-plan', tmpDir);
assertNoLockFile();
});
test('cmdStateUpdateProgress releases lock (state update-progress)', () => {
runGsdTools('state update-progress', tmpDir);
assertNoLockFile();
});
test('cmdStateAddDecision releases lock (state add-decision)', () => {
runGsdTools('state add-decision --phase 01 --summary "Use TypeScript"', tmpDir);
assertNoLockFile();
});
test('cmdStateAddBlocker releases lock (state add-blocker)', () => {
runGsdTools('state add-blocker --text "Blocked on review"', tmpDir);
assertNoLockFile();
});
test('cmdStateRecordSession releases lock (state record-session)', () => {
runGsdTools('state record-session --stopped-at "context exhaustion at 80%"', tmpDir);
assertNoLockFile();
});
test('cmdStateBeginPhase releases lock (state begin-phase)', () => {
runGsdTools('state begin-phase 01', tmpDir);
assertNoLockFile();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Roadmap analyze behavioral assertion (no timing gate)
// Replaces concurrency-safety:794 (elapsed < ROADMAP_ANALYZE_BUDGET_MS)
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze behavioral correctness (50-phase)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
_create50PhaseProject(tmpDir, 25);
});
afterEach(() => {
cleanup(tmpDir);
});
function _create50PhaseProject(dir, completedCount) {
let roadmapContent = '# Roadmap v1.0\n\n';
for (let i = 1; i <= 50; i++) {
roadmapContent += `- [${i <= completedCount ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
}
roadmapContent += '\n';
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
roadmapContent += `### Phase ${i}: Feature ${i}\n\n`;
roadmapContent += `**Goal:** Build feature ${i}\n`;
roadmapContent += `**Requirements:** REQ-${pad}\n`;
roadmapContent += `**Plans:** 1 plans\n\n`;
roadmapContent += `Plans:\n- [${i <= completedCount ? 'x' : ' '}] ${pad}-01-PLAN.md\n\n`;
}
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), roadmapContent);
const phasesDir = path.join(dir, '.planning', 'phases');
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
const phaseDir = path.join(phasesDir, `${pad}-feature-${i}`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Phase ${i} Plan 1\n`);
if (i <= completedCount) {
fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Phase ${i} Summary\n`);
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes a phase count as complete now.
fs.writeFileSync(path.join(phaseDir, `${pad}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n');
}
}
}
test('roadmap analyze returns 50 phases with 25 complete (behavioral, no timing gate)', () => {
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `roadmap analyze must succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.phases), 'output must contain phases array');
assert.strictEqual(output.phases.length, 50, `must return 50 phases, got ${output.phases.length}`);
const completedPhases = output.phases.filter(p => p.disk_status === 'complete');
assert.strictEqual(completedPhases.length, 25, `must have 25 complete phases, got ${completedPhases.length}`);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-474-clock-seam-date-determinism.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-474-clock-seam-date-determinism (consolidation epic #1969 B3 #1972)", () => {
// allow-test-rule: source-text-is-the-product (see #474)
// STATE.md is the product surface; assertions on its text content test the
// deployed contract (date field written by the subprocess SUT).
'use strict';
/**
* Bug #474 — clock seam: subprocess date-stamping must be deterministic.
*
* Tests in this file verify that:
* 1. state.cjs date-stamping is routed through realClock (not bare new Date()),
* so GSD_NOW_MS pins the written date deterministically in subprocess tests.
* 2. installer-migrations.cjs lock-loop timeout fires deterministically via
* the clock seam (in-process, using makeFakeClock — no subprocess needed).
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, cleanup } = require('./helpers.cjs');
const { createFixture } = require('./fixtures/index.cjs');
const { makeFakeClock } = require('./helpers/clock.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// §1 Subprocess date-pin: state advance-plan writes the pinned date
// ─────────────────────────────────────────────────────────────────────────────
/**
* Why advance-plan?
* cmdStateAdvancePlan captures `const today = new Date().toISOString().split('T')[0]`
* and writes it to "Last Activity" via stateReplaceFieldIfTemplate.
* The fixture below has Last Activity = 2024-01-10 (an ISO date, treated as a
* handler-generated template default by isStateTemplateDefault), so the field IS
* overwritten — making this the simplest single-subcommand probe of the bug.
*/
// A fixed historical instant far in the past — will NEVER match today's real date.
const PINNED_MS = Date.parse('2020-06-15T12:00:00.000Z');
const PINNED_DATE = '2020-06-15';
// A minimal STATE.md that satisfies advance-plan's parser:
// - Current Plan: 1 (not on last plan → normal-advance branch)
// - Total Plans in Phase: 3
// - Last Activity: 2024-01-10 (ISO date → isStateTemplateDefault returns true → will be replaced)
const ADVANCE_FIXTURE = [
'# Project State',
'',
'**Current Plan:** 1',
'**Total Plans in Phase:** 3',
'**Status:** Executing',
'**Last Activity:** 2024-01-10',
].join('\n') + '\n';
describe('bug-474: state date-stamping is pinned by GSD_NOW_MS', () => {
let tmpDir;
before(() => {
// AAA — Arrange: create a temp project with the advance fixture
tmpDir = createFixture();
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), ADVANCE_FIXTURE);
});
after(() => {
cleanup(tmpDir);
});
test('state advance-plan writes pinned date (not real today) when GSD_NOW_MS is set', () => {
// AAA — Act: run advance-plan with a pinned historical timestamp
const result = runGsdTools('state advance-plan', tmpDir, {
GSD_TEST_MODE: '1',
GSD_NOW_MS: String(PINNED_MS),
});
assert.ok(result.success, `advance-plan failed unexpectedly: ${result.error}`);
// AAA — Assert: the written STATE.md must contain the pinned date
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// Must contain the pinned historical date (2020-06-15)
assert.ok(
written.includes(PINNED_DATE),
`Expected STATE.md to contain pinned date ${PINNED_DATE}.\nActual STATE.md:\n${written}`,
);
// Must NOT contain today's real date — that would mean the seam is bypassed
const realToday = new Date().toISOString().split('T')[0];
assert.ok(
!written.includes(realToday),
`Expected STATE.md NOT to contain real today (${realToday}) when time is pinned.\nActual STATE.md:\n${written}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// §2 realClock GSD_NOW_MS invalid-input hardening
// ─────────────────────────────────────────────────────────────────────────────
/**
* Verify that malformed / out-of-range GSD_NOW_MS values fall back to the real
* clock instead of crashing with RangeError (issue #474 hardening).
*
* Each invalid input must:
* a) not throw from realClock.nowIso(), and
* b) produce a valid parseable ISO string (i.e. fell back to Date.now()).
*
* A valid pinned value must produce the expected date string.
*/
describe('bug-474: realClock GSD_NOW_MS invalid-input hardening', () => {
const realClock = require('../gsd-core/bin/lib/clock.cjs').realClock;
// Save and restore env so these tests cannot bleed into neighbouring tests.
let savedTestMode;
let savedNowMs;
before(() => {
savedTestMode = process.env.GSD_TEST_MODE;
savedNowMs = process.env.GSD_NOW_MS;
process.env.GSD_TEST_MODE = '1';
});
after(() => {
if (savedTestMode === undefined) {
delete process.env.GSD_TEST_MODE;
} else {
process.env.GSD_TEST_MODE = savedTestMode;
}
if (savedNowMs === undefined) {
delete process.env.GSD_NOW_MS;
} else {
process.env.GSD_NOW_MS = savedNowMs;
}
});
// AAA matrix: each invalid value must NOT crash and must fall back to the real clock.
const INVALID_INPUTS = [
['empty string', ''],
['whitespace only', ' '],
['alphabetic', 'abc'],
['scientific notation', '1e30'],
['decimal float', '12.5'],
['integer > 8.64e15', '99999999999999999999'],
];
for (const [label, value] of INVALID_INPUTS) {
test(`GSD_NOW_MS='${value}' (${label}) falls back to real clock — no crash, valid ISO`, () => {
// AAA — Arrange
process.env.GSD_NOW_MS = value;
// AAA — Act + Assert: must not throw
assert.doesNotThrow(
() => realClock.nowIso(),
`realClock.nowIso() must not throw for GSD_NOW_MS='${value}'`,
);
// AAA — Assert: result is a valid ISO string (fell back to real clock)
const iso = realClock.nowIso();
assert.ok(
!Number.isNaN(Date.parse(iso)),
`realClock.nowIso() must return a valid ISO date for GSD_NOW_MS='${value}', got: ${iso}`,
);
});
}
test('GSD_NOW_MS valid decimal integer pins the clock', () => {
// AAA — Arrange: a known historical epoch
process.env.GSD_NOW_MS = String(PINNED_MS);
// AAA — Act
const tod = realClock.today();
// AAA — Assert
assert.strictEqual(tod, PINNED_DATE, `realClock.today() must return pinned date ${PINNED_DATE}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// §3 In-process lock-loop: installer-migrations timeout fires deterministically
// ─────────────────────────────────────────────────────────────────────────────
/**
* This test exercises acquireInstallMigrationLock's EEXIST retry loop via a
* makeFakeClock. The approach:
* - Write a lock file held by PID 1 (init/launchd — always alive on any OS)
* so neither isSameProcess nor isDeadProcess is true; stale-lock reclamation
* is NOT triggered, and the loop must reach the timeout check.
* - Use TIMEOUT_MS = 500 (non-zero) so the loop must retry at least once before
* the injected clock trips the deadline. A zero timeout would fire on the
* very first check without exercising clock.sleep() at all.
* - The fake clock's sleep(ms) ADVANCES its internal now by ms (confirmed from
* helpers/clock.cjs implementation), so the loop drives itself to termination
* purely through the injected clock without any wall-clock delay.
* - Post-throw assertions on clock.sleepCalls and clock.now() prove the seam:
* if the loop reverted to raw Date.now()/sleepSync, sleepCalls would be 0.
*/
describe('bug-474: installer-migrations lock-loop timeout is deterministic via clock seam', () => {
test('makeFakeClock nowIso() and today() derive from pinned now()', () => {
// AAA — Arrange
const clock = makeFakeClock(PINNED_MS);
// AAA — Act
const iso = clock.nowIso();
const tod = clock.today();
// AAA — Assert
assert.strictEqual(iso, '2020-06-15T12:00:00.000Z', 'nowIso() must return ISO string of pinned epoch');
assert.strictEqual(tod, PINNED_DATE, 'today() must return YYYY-MM-DD of pinned epoch');
});
test('makeFakeClock advance() shifts nowIso() and today()', () => {
// AAA — Arrange: start at PINNED_MS, advance by 24 h
const clock = makeFakeClock(PINNED_MS);
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
// AAA — Act
clock.advance(ONE_DAY_MS);
// AAA — Assert
assert.strictEqual(clock.today(), '2020-06-16', 'today() must reflect advanced time');
});
test('acquireInstallMigrationLock timeout path fires deterministically via makeFakeClock', (t) => {
// AAA — Arrange
const os = require('os');
const { acquireInstallMigrationLock } = require('../gsd-core/bin/lib/installer-migrations.cjs');
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-474-lock-'));
t.after(() => {
cleanup(configDir);
});
const LOCK_NAME = 'gsd-install-migration.lock';
const lockPath = path.join(configDir, LOCK_NAME);
// Write a lock file held by process.ppid (the parent process, always alive
// and never equal to process.pid on every platform). pid 1 was used
// previously but isPidAlive(1) returns false on Windows (no init/launchd
// pid-1 concept), so the lock was treated as stale, reclaimed, and
// acquireInstallMigrationLock succeeded instead of throwing — failing the
// timeout assertion on windows-latest,22 (#474). process.ppid is a live,
// non-self process on all platforms, so the lock is correctly seen as held
// and the timeout path throws deterministically cross-platform.
fs.writeFileSync(
lockPath,
JSON.stringify({ pid: process.ppid, acquiredAt: new Date().toISOString() }) + '\n',
);
// TIMEOUT_MS is non-zero so the first EEXIST iteration does NOT immediately
// trip the deadline. The loop must call clock.sleep() at least once; that
// sleep() advances the fake clock past TIMEOUT_MS, causing the next check to
// throw. This proves the seam: if the loop used raw Date.now()/sleepSync,
// clock.sleepCalls would remain 0.
const TIMEOUT_MS = 500;
const clock = makeFakeClock(0);
// AAA — Act + Assert: timeout error thrown with no real wall-clock delay
assert.throws(
() => acquireInstallMigrationLock(configDir, { timeoutMs: TIMEOUT_MS }, clock),
/lock|held/i,
'Expected acquireInstallMigrationLock to throw with "lock"/"held" in message on timeout',
);
// AAA — Assert seam was actually exercised through the injected clock:
// If the loop used raw sleepSync instead of clock.sleep(), sleepCalls would be 0.
assert.ok(
clock.sleepCalls.length > 0,
'loop must retry via injected clock.sleep() — proves seam wiring, not raw wall clock',
);
// The injected clock must have advanced past TIMEOUT_MS through its own sleep() calls.
assert.ok(
clock.now() >= TIMEOUT_MS,
`injected clock advanced past timeout deterministically: clock.now()=${clock.now()} must be >= TIMEOUT_MS=${TIMEOUT_MS}`,
);
});
});
});
}