Files
msd-core/tests/gsd-statusline-state.property.test.cjs
Tom Boucher adb46cdd85 feat(#2734): surface STATE.md commit-age on the statusline (#3700)
* test(#2734): failing-first suite for the statusline STATE.md freshness marker

Binds the contract before any hook change exists: a `state ~N commits back`
segment gated on the state_head stamp landed by #2622, firing at the same
advisory threshold /gsd-health's W024 uses rather than at > 0.

Covers all five acceptance criteria — threshold parity (19/20/21 boundaries),
both renderers including formatGsdStateCompact, an exact spawn-count assertion,
repo-pinning and sub_repos degradation, and behavioral parity against
readStateHeadFreshness rather than a source-grep of the two fence copies.

52 example-based tests plus 5 seeded fast-check properties. Red now by design.

* feat(#2734): surface STATE.md commit-age on the statusline

Adds an opt-in `state ~N commits back` marker to the GSD-state segment,
consuming the `state_head` stamp and freshness contract landed by #2622.
A solo developer returning to a project reads "Phase 4, executing" in
STATE.md and acts on it, without noticing the codebase moved 40 commits
since that line was written. /gsd-health reports it as W024, but only if
you think to run it; the statusline is the surface you see without asking.

Fires at STATE_HEAD_ADVISORY_COMMITS (20), the same threshold W024 uses,
not at > 0: with commit_docs:true the commit carrying a STATE.md sync
advances HEAD by one, so > 0 would alarm permanently on a fresh project.

Costs exactly one bounded git subprocess per render and none when
disabled. `rev-list --left-right --count` answers ancestry and distance
together, and repo pinning is a filesystem check mirroring
projectOwnsItsRepo rather than a --show-toplevel compare, which is
unreliable on macOS /private/var and Windows 8.3 paths.

Every unresolvable input degrades to the tri-state unknown -- the marker
is absent, never a "fresh" claim the project cannot substantiate: a
malformed stamp, a root that does not own its .git, a sub_repos
workspace, history rewound past the stamp, or git being unavailable.

Also collapses statusline config resolution onto one resolveStatuslineOptions()
seam. runStatusline() and renderStatusline() duplicated it byte-for-byte;
one copy is what keeps a newly-added key from reaching only one of them.

* test(#2734): route the e2e spawn through the process seam and fix fixture leaks

Review findings from the two orthogonal passes:

- `bothEntryPointsResolveOptionsIdentically` spawned a child and substring-matched
  its stdout to test a pure function. It now calls resolveStatuslineOptions()
  directly — no subprocess, no text matching.
- `skipsFreshnessWorkWhenTodoTaskActive` genuinely needs a child (the !task gate
  lives in runStatusline, which reads stdin), so it now spawns through
  tests/helpers/process-seam.cjs and proves the negative with a filesystem fact:
  the git shim appends to a marker file on every invocation, and the assertion is
  that the marker never appears. Stronger than asserting text is missing, and it
  drops the last stdout substring match in the block.
- Every fixture-creating test now registers `t.after(() => cleanup(dir))` instead
  of a trailing cleanup(dir), which leaked the temp repo on assertion failure.
  derivationAgreesWithStateModule reassigns `dir` across five fixtures, so it
  binds each directory at scheduling time rather than cleaning only the last.

Also corrects markerCoexistsWithMilestoneComplete, which asserted the wrong
expectation rather than finding a code defect: `percent` drives the progress bar
too, so the milestone segment reads "v1.9 [##########] 100%". The marker appends
after it, which is what the test exists to prove.

CONTEXT.md's opt-in statusline key list was missing statusline.show_git as well
as the new key; both are now enumerated.

* docs(#2734): backfill changeset PR number (#3700)

---------

Co-authored-by: sim <sim@local>
2026-08-20 00:35:01 -04:00

172 lines
6.2 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';
/**
* Property tests for the compact GSD-state status normalizer (#2162).
*
* shortGsdStatus() collapses free-text STATE.md statuses to a canonical
* keyword (or a capped first-word fallback). As a parsing/normalization
* contract it gets property coverage per the repo testing rules, alongside
* the example-based cases in gsd-statusline.test.cjs.
*/
const { test, describe } = require('node:test');
const fc = require('./helpers/fast-check-setup.cjs');
const { shortGsdStatus } = require('../hooks/gsd-statusline.js');
const CANONICAL = ['discussing', 'planning', 'executing', 'verifying', 'completed', 'paused'];
describe('shortGsdStatus properties (#2162)', () => {
test('totality: any string input yields null or a short non-empty string', () => {
fc.assert(
fc.property(fc.string(), (s) => {
const out = shortGsdStatus(s);
if (out === null) return true;
return typeof out === 'string' && out.length > 0 && out.length <= 16;
}),
);
});
test('canonical statuses are fixed points (paused shouts as PAUSED)', () => {
fc.assert(
fc.property(fc.constantFrom(...CANONICAL), (canonical) => {
const out = shortGsdStatus(canonical);
return canonical === 'paused' ? out === 'PAUSED' : out === canonical;
}),
);
});
test('output never contains whitespace or separator characters', () => {
// The compact line joins segments with ' · ' — a status containing
// whitespace or the separator would corrupt the segment structure.
fc.assert(
fc.property(fc.string(), (s) => {
const out = shortGsdStatus(s);
return out === null || !/[\s·—–]/.test(out);
}),
);
});
test('unrecognized free text falls back to its first word, capped at 16', () => {
// Alphabetic words that are not canonical and don't contain canonical
// trigger substrings exercise the fallback path deterministically.
const word = fc.stringMatching(/^[A-Za-z]{1,32}$/).filter((w) => {
const lower = w.toLowerCase();
return !CANONICAL.some((c) => lower.includes(c.slice(0, 4)));
});
fc.assert(
fc.property(word, word, (first, second) => {
const out = shortGsdStatus(`${first} ${second}`);
// The normalizer may still map some phrasings to a canonical keyword
// (e.g. synonym tables); otherwise the first word survives, capped.
return out === null || CANONICAL.concat('PAUSED').includes(out) || out === first.slice(0, 16);
}),
);
});
});
/**
* Property tests for STATE.md freshness marker derivation (#2734).
*
* These properties are asserted against `deriveStateFreshness`,
* `isValidStateHeadStamp`, `formatStateFreshness`, `formatGsdState`, and
* `formatGsdStateCompact` per `.gsd/phase/feat-2734-statusline-state-freshness/50-test-matrix.md`
* rows P1-P5. None of these symbols exist on `hooks/gsd-statusline.js` yet —
* this block is deliberately failing-first TDD.
*/
const {
STATE_HEAD_ADVISORY_COMMITS,
isValidStateHeadStamp,
deriveStateFreshness,
formatStateFreshness,
formatGsdState: formatGsdStateFull,
formatGsdStateCompact: formatGsdStateCompactFn,
} = require('../hooks/gsd-statusline.js');
describe('state-head freshness properties (#2734)', () => {
test('derivationIsTotalOverArbitraryStamps', () => {
const root = require('node:os').tmpdir();
fc.assert(
fc.property(fc.string(), (stamp) => {
const ir = deriveStateFreshness(root, stamp);
if (!ir || typeof ir !== 'object') return false;
const keys = Object.keys(ir).sort().join(',');
if (keys !== ['commit_stale', 'commits_behind', 'state_head'].sort().join(',')) return false;
const cb = ir.commits_behind;
return cb === null || (Number.isInteger(cb) && cb >= 0);
}),
);
});
test('fenceAcceptsOnlyHexInRange', () => {
fc.assert(
fc.property(fc.string(), (s) => {
const expected = /^[0-9a-f]{4,40}$/i.test(String(s).trim());
return isValidStateHeadStamp(s) === expected;
}),
);
});
test('markerVisibilityIsMonotonicInCount', () => {
fc.assert(
fc.property(fc.nat({ max: 1000000 }), (n) => {
const out = formatStateFreshness({ commits_behind: n });
const shouldShow = n >= STATE_HEAD_ADVISORY_COMMITS;
return shouldShow ? out !== '' : out === '';
}),
);
});
test('unknownNeverDegradesToFresh', () => {
const root = require('node:os').tmpdir();
fc.assert(
fc.property(fc.string(), (stamp) => {
const ir = deriveStateFreshness(root, stamp);
return ir.commit_stale === null;
}),
);
});
test('renderersAreTotalOverArbitraryIr', () => {
const optionalString = fc.oneof(fc.constant(undefined), fc.string());
const optionalCount = fc.oneof(fc.constant(undefined), fc.nat({ max: 999 }));
const hasBadSeparator = (str) => {
const trimmed = str.trim();
if (trimmed.startsWith('·') || trimmed.endsWith('·')) return true;
return str.includes('··') || str.includes('· ·');
};
fc.assert(
fc.property(
fc.record({
status: optionalString,
phaseNum: optionalCount,
phaseTotal: optionalCount,
phaseName: optionalString,
milestone: optionalString,
milestoneName: optionalString,
percent: fc.oneof(fc.constant(undefined), fc.nat({ max: 100 })),
activePhase: optionalString,
nextAction: optionalString,
nextPhases: fc.oneof(fc.constant(undefined), fc.array(fc.string(), { maxLength: 3 })),
completedPhases: optionalCount,
totalPhases: optionalCount,
noActiveWorkstream: fc.boolean(),
freshness: fc.record({
state_head: fc.oneof(fc.constant(null), fc.string()),
commits_behind: fc.oneof(fc.constant(null), fc.nat({ max: 1000000 })),
commit_stale: fc.oneof(fc.constant(null), fc.boolean()),
}),
}),
(s) => {
const full = formatGsdStateFull(s);
const compact = formatGsdStateCompactFn(s);
if (typeof full !== 'string' || typeof compact !== 'string') return false;
return !hasBadSeparator(full) && !hasBadSeparator(compact);
},
),
);
});
});