refactor(#1191): inject clock/reset testability seams + handle valid-null settings (#1233)

* refactor(#1191): inject clock/reset testability seams + handle valid-null settings

- worktree-safety reapOrphanWorktrees: injectable deps.nowMs clock for deterministic stale-lock boundary tests (mirrors snapshotWorktreeInventory's options.nowMs).

- active-workstream-store: _resetControllingTtyCacheForTests() seam clears the memoized controlling-TTY probe cache; test replaces require.cache busting.

- gen-capability-registry: export stripGeneratedComment (additive); test imports the real helper + equivalence assertion, keeping the deliberate drift oracle.

- install.js readSettings: a successfully-parsed JSON null is treated as empty settings ({}) instead of being mis-reported as malformed; genuine parse failures still warn. readSettings/stripJsonComments exported (GSD_TEST_MODE-guarded require) for real behavioral tests.

Closes #1191

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1191): add changeset for valid-null settings fix (#1233)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1191): replace Stryker-incompatible structural reset test with behavioral isTTY-spy

The seam-2 reset test read the BUILT active-workstream-store.cjs and grepped for 'didProbeControllingTtyToken = false' — Stryker instruments that file so the literal is absent, failing the mutation DRY RUN. Replaced with a behavioral test that spies on process.stdin.isTTY access count to prove a post-reset probe re-runs (kills the didProbe-reset mutant) without reading source text. Local stryker: dry run passes, score 85.21% >= 80.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-14 20:52:56 -04:00
committed by GitHub
parent 55eb1fd72e
commit b783410815
9 changed files with 453 additions and 68 deletions

View File

@@ -25,6 +25,7 @@ const {
parseCliWorkstream,
resolveActiveWorkstream,
applyResolvedWorkstreamEnv,
_resetControllingTtyCacheForTests,
} = require('../gsd-core/bin/lib/active-workstream-store.cjs');
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -361,17 +362,11 @@ describe('getWorkstreamSessionKey', () => {
// Force a deterministic non-TTY environment so the probe path is exercised
// regardless of whether this runs in a real developer terminal.
//
// Rationale for require.cache bust: active-workstream-store.cjs caches the
// controlling-TTY probe result in module-level vars (cachedControllingTtyToken /
// didProbeControllingTtyToken). By the time this test runs, an earlier
// pickActiveWorkstreamAdapter call has already set didProbeControllingTtyToken=true
// with whatever the real TTY probe returned. Busting the module cache gives us a
// fresh module with zeroed-out state, so overriding process.stdin.isTTY=false
// actually reaches the isTTY branch and returns null.
//
// Residual: if a reset seam (e.g. resetControllingTtyCache()) is ever exposed by
// the module, replace the cache-bust with that call (tracked in #1191).
const modulePath = require.resolve('../gsd-core/bin/lib/active-workstream-store.cjs');
// Uses the _resetControllingTtyCacheForTests() seam (#1191) to clear the
// module-level memoized TTY probe result (cachedControllingTtyToken /
// didProbeControllingTtyToken) without busting the require.cache. This
// ensures overriding process.stdin.isTTY=false actually reaches the isTTY
// branch and returns null, even if an earlier test already ran the probe.
const savedIsTTY = process.stdin.isTTY;
try {
// Clear TTY/SSH_TTY (already done by beforeEach, but be explicit).
@@ -379,18 +374,14 @@ describe('getWorkstreamSessionKey', () => {
delete process.env.SSH_TTY;
// Override isTTY so probeControllingTtyToken() takes the non-TTY branch.
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
// Bust the module cache so didProbeControllingTtyToken resets to false.
delete require.cache[modulePath];
const fresh = require(modulePath);
const key = fresh.getWorkstreamSessionKey();
// Reset the memoized probe cache via the seam so the probe re-runs.
_resetControllingTtyCacheForTests();
const key = getWorkstreamSessionKey();
assert.strictEqual(key, null);
} finally {
// Restore isTTY and module cache entry.
// Restore isTTY and reset the cache so subsequent tests get a clean probe.
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true, writable: true });
delete require.cache[modulePath];
// Re-prime the cache with the original module instance so the rest of
// this describe block continues to use the top-level import binding.
require(modulePath);
_resetControllingTtyCacheForTests();
}
});
@@ -929,3 +920,179 @@ describe('applyResolvedWorkstreamEnv', () => {
assert.equal(env.GSD_WORKSTREAM, 'old');
});
});
// ── _resetControllingTtyCacheForTests seam (#1191): both cache fields reset ──
describe('_resetControllingTtyCacheForTests: proves BOTH cache fields cleared (#1191)', () => {
// The module holds two fields for the TTY probe memo:
// cachedControllingTtyToken — the result of the last probe
// didProbeControllingTtyToken — prevents re-probing once set
//
// A broken reset that only clears the token but leaves didProbeControllingTtyToken=true
// would still allow getWorkstreamSessionKey to "work" (returning the stale null token)
// in a non-TTY CI environment — because the probe short-circuits but the stale value
// is null either way.
//
// LIMITATION: In a non-TTY CI environment (process.stdin.isTTY = false),
// probeControllingTtyToken() returns null WHETHER OR NOT didProbeControllingTtyToken
// was cleared (because probeTty() → "not a tty" → null in both cases).
// The deterministic distinguishing assertion (mutation detects RED) therefore
// requires a real controlling TTY, i.e. process.stdin.isTTY=true AND probeTty()
// returning a real tty path.
//
// In non-TTY CI this test falls back to a structural assertion that the reset is
// non-trivially correct (both fields are present in source, both are zeroed).
// The TTY-conditional branch IS exercised in dev environments and provides full
// mutation coverage there.
test('reset is a non-no-op: repeated calls with isTTY=false return consistent null', () => {
// Prove reset is not completely absent: prime, reset, verify null still returned.
const savedIsTTY = process.stdin.isTTY;
const saved = saveSessionEnv();
clearSessionEnv();
try {
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
// First probe: sets didProbe=true, cached=null
_resetControllingTtyCacheForTests();
const k1 = getWorkstreamSessionKey();
assert.strictEqual(k1, null, 'first call returns null in non-TTY env');
// Reset: must clear both fields
_resetControllingTtyCacheForTests();
const k2 = getWorkstreamSessionKey();
assert.strictEqual(k2, null, 'second call after reset returns null in non-TTY env');
} finally {
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true, writable: true });
restoreSessionEnv(saved);
_resetControllingTtyCacheForTests();
}
});
test('reset clears didProbeControllingTtyToken: re-probe reflects changed isTTY (TTY-conditional)', () => {
// MUTATION TRAP: if _resetControllingTtyCacheForTests() does NOT clear
// didProbeControllingTtyToken, probeControllingTtyToken() short-circuits and
// returns the stale cached token instead of re-probing.
//
// Strategy:
// 1. Prime cache with isTTY=true so probeControllingTtyToken() runs the
// probe path (and possibly caches a non-null token in dev environments).
// 2. Call reset.
// 3. Switch to isTTY=false and verify the result reflects the NEW isTTY=false
// environment (i.e. null), proving re-probe ran.
//
// In a non-TTY CI environment, the probe path at step 1 also yields null
// (probeTty → "not a tty"), so after reset + isTTY=false the result is null
// in BOTH the correct and broken cases. In that case this test still PASSES
// (it asserts null), but does not distinguish — the mutation would survive CI.
// The test is intentionally skipped for its mutation-distinguishing claim in
// non-TTY CI; it runs fully in dev environments with a real controlling TTY.
const savedIsTTY = process.stdin.isTTY;
const saved = saveSessionEnv();
clearSessionEnv();
delete process.env.TTY;
delete process.env.SSH_TTY;
try {
// Step 1: prime probe cache with isTTY=true
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true, writable: true });
_resetControllingTtyCacheForTests(); // start clean
// Call getWorkstreamSessionKey with no session env → falls through to TTY probe path.
// In a real dev TTY this sets cachedControllingTtyToken='tty-...' and didProbe=true.
// In CI (probeTty→null) this sets cached=null and didProbe=true.
const primed = getWorkstreamSessionKey();
// Step 2: reset both fields
_resetControllingTtyCacheForTests();
// Step 3: switch to isTTY=false — fresh probe must yield null
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
const afterReset = getWorkstreamSessionKey();
// In a dev TTY: primed is 'tty-...' (non-null), afterReset must be null (re-probe ran).
// In CI: primed is null, afterReset is null (both cases produce null — mutation survives).
// Either way, afterReset must be null.
assert.strictEqual(afterReset, null,
'after reset + isTTY=false, probe must return null (proves re-probe ran in TTY environments)');
if (primed !== null) {
// We're in a real TTY environment: primed was non-null, afterReset is null.
// This PROVES didProbeControllingTtyToken was cleared and the probe re-ran.
// A broken reset (clear token only) would have returned the stale null cached
// value ONLY if the token was also cleared — but since primed was non-null,
// a "clear token only" broken reset would set cached=null and leave didProbe=true,
// causing the probe to skip and return null regardless. So the assertion still
// passes in both correct and broken cases once the token is cleared.
//
// The true distinguishing scenario requires: prime→non-null cached; broken reset
// leaves didProbe=true AND cached='tty-X' (doesn't clear token either). But the
// described mutation IS "clear token but not didProbe", which sets cached=null →
// same observable outcome. See LIMITATION note above.
//
// Conclusion: in a TTY environment the test exercises both code paths and passes.
// Mutation survives only if tested in CI (non-TTY). Full mutation isolation
// requires a stubbable probeTty seam (not currently exposed).
}
} finally {
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true, writable: true });
restoreSessionEnv(saved);
_resetControllingTtyCacheForTests();
}
});
test('reset clears didProbeControllingTtyToken: isTTY getter re-invoked after reset (mutation kill)', () => {
// MUTATION TRAP: if _resetControllingTtyCacheForTests() does NOT clear
// didProbeControllingTtyToken (i.e. leaves it true), probeControllingTtyToken()
// short-circuits on the first `if (didProbeControllingTtyToken) return cached`
// guard and never accesses process.stdin.isTTY.
//
// Strategy: spy on the process.stdin.isTTY getter via Object.defineProperty to
// count how many times the probe body accesses it.
//
// Probe #1 (after reset): didProbe=false → probe body runs → isTTY accessed → count+1
// Probe #2 (no reset): didProbe=true → short-circuit → isTTY NOT accessed → count unchanged
// Probe #3 (after reset): if reset cleared didProbe → probe body runs → isTTY accessed → count+1
// if reset did NOT clear didProbe (mutant) → short-circuit → count unchanged
//
// Assert: count increases between probe #1 and probe #2 baseline, and again after probe #3.
// The failing assert for the mutant is: count after probe #3 > count before probe #3.
const saved = saveSessionEnv();
clearSessionEnv();
const origDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
let accessCount = 0;
try {
// Install getter spy — return false so probeTty is not invoked (CI-safe).
Object.defineProperty(process.stdin, 'isTTY', {
get() { accessCount++; return false; },
configurable: true,
});
// Probe #1: fresh start, didProbe=false → body runs → isTTY read
_resetControllingTtyCacheForTests();
getWorkstreamSessionKey(); // falls through all session env keys → probeControllingTtyToken()
const countAfterProbe1 = accessCount;
assert.ok(countAfterProbe1 >= 1,
'probe #1: isTTY must be accessed at least once (probe body ran)');
// Probe #2: no reset → didProbe=true → short-circuit → isTTY NOT accessed
getWorkstreamSessionKey();
const countAfterProbe2 = accessCount;
assert.equal(countAfterProbe2, countAfterProbe1,
'probe #2: isTTY must NOT be accessed again (memoized — didProbe=true)');
// Probe #3: reset → didProbe must be false again → probe body runs → isTTY accessed
_resetControllingTtyCacheForTests();
getWorkstreamSessionKey();
const countAfterProbe3 = accessCount;
assert.ok(countAfterProbe3 > countAfterProbe2,
'probe #3: isTTY must be accessed again after reset (proves didProbeControllingTtyToken was cleared)');
} finally {
// Restore original descriptor (may be undefined if property was inherited)
if (origDescriptor) {
Object.defineProperty(process.stdin, 'isTTY', origDescriptor);
} else {
// Delete the spy property so the prototype value is visible again
delete (process.stdin).isTTY;
}
restoreSessionEnv(saved);
_resetControllingTtyCacheForTests();
}
});
});