Files
msd-core/tests/gsd-statusline.test.cjs
Tom Boucher a28dcec981 chore(#597): replace count-based ratchet guards with AST lint + named-set allowlists (#603)
The windows-test-parity ratchet greps test source for fs.rmSync-without-
maxRetries (and six other Windows-portability anti-patterns), failing when an
integer offender COUNT exceeds a frozen baseline (rmSync: 95). A count ratchet
is a Goodhart metric: fixing one offender and adding another keeps the count
constant, so a new defect slips through green. Replace it — and every other
count ratchet in the repo — with a layered, masking-proof design.

Behavioral seam test
- tests/helpers-cleanup.test.cjs proves helpers.cleanup() carries the Windows
  EBUSY retry budget. cleanup() delegates retries to Node's fs.rmSync via
  maxRetries (it owns no loop), so the test asserts the option contract
  (recursive/force/maxRetries>0/retryDelay>0) + real-FS removal + the cwd-guard,
  rather than a loop that does not exist. The EBUSY risk is now tested ONCE at
  the helper, not approximated textually at every call site.

Write-time ESLint rule (AST-accurate, replaces the grep)
- eslint-rules/no-raw-rmsync-in-tests.cjs (error in tests/**/*.test.cjs) bans
  raw fs.rmSync, steering to cleanup(). Catches member, computed (fs['rmSync']),
  destructured and aliased forms; escape hatch is inline
  `// eslint-disable-next-line local/no-raw-rmsync-in-tests -- <reason>` only.
- Migrated 336 raw fs.rmSync teardown calls across ~116 test files to cleanup().
  ~18 genuinely load-bearing sites (mid-test SUT/fault-injection removals,
  error-swallowing or name-colliding local teardown helpers) keep the raw call
  with an inline eslint-disable + reason.

Shared anti-ratchet primitive
- scripts/lib/allowlist-ratchet.cjs:
  - assertWithinAllowlist: fails on NOVEL ids (new offender introduced) AND on
    STALE ids (a known offender was fixed but not pruned) — identity, not count,
    and a ratchet DOWN toward zero.
  - assertTightCeiling: a size/length budget whose ceiling must stay within a
    grace band of the high-water mark, so budgets may only tighten, never creep.

Ratchets converted onto the primitive
- windows-test-parity-guard.test.cjs: rmSync rule deleted (now ESLint-enforced);
  the remaining six patterns moved from integer baselines to named-set
  allowlists with ratchet-down.
- scripts/lint-test-file-count.{cjs,allowlist.json}: per-module integer counts →
  named filename sets (closes the swap-a-file-keep-the-count blind spot); a
  module dropping under cap now FAILS to force pruning its allowlist entry.
- enh-2790 skill-count `<= 63` → named skill allowlist (ratchets toward ~58).

Size budgets hardened (tighten-only)
- agent-size / workflow-size / feat-3039 help-tiered: ceilings lowered to the
  current high-water mark and an assertTightCeiling anti-creep check added per
  tier. Fixed external-contract limits (description ≤100 chars, agent ≤100 KB)
  are intentionally left as-is — they are not grandfathered creeping budgets.

No user-facing behavior change (tests + tooling only); no USER_FACING_PREFIXES
touched, so no changeset fragment is required.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:43:49 -04:00

470 lines
16 KiB
JavaScript

/**
* Tests for gsd-statusline.js GSD state display helpers.
*
* Covers:
* - parseStateMd across YAML-frontmatter, body-fallback, and partial formats
* - formatGsdState graceful degradation when fields are missing
* - readGsdState walk-up search with proper bounds
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const {
parseStateMd,
formatGsdState,
readGsdState,
isInstalledAheadOfLatest,
} = require('../hooks/gsd-statusline.js');
const { cleanup } = require('./helpers.cjs');
// ─── parseStateMd ───────────────────────────────────────────────────────────
describe('parseStateMd', () => {
test('parses full YAML frontmatter', () => {
const content = [
'---',
'status: executing',
'milestone: v1.9',
'milestone_name: Code Quality',
'---',
'',
'# State',
'Phase: 1 of 5 (fix-graphiti-deployment)',
].join('\n');
const s = parseStateMd(content);
assert.equal(s.status, 'executing');
assert.equal(s.milestone, 'v1.9');
assert.equal(s.milestoneName, 'Code Quality');
assert.equal(s.phaseNum, '1');
assert.equal(s.phaseTotal, '5');
assert.equal(s.phaseName, 'fix-graphiti-deployment');
});
test('treats literal "null" values as null', () => {
const content = [
'---',
'status: null',
'milestone: null',
'milestone_name: null',
'---',
].join('\n');
const s = parseStateMd(content);
assert.equal(s.status, null);
assert.equal(s.milestone, null);
assert.equal(s.milestoneName, null);
});
test('strips surrounding quotes from frontmatter values', () => {
const content = [
'---',
'milestone_name: "Code Quality"',
"milestone: 'v1.9'",
'---',
].join('\n');
const s = parseStateMd(content);
assert.equal(s.milestone, 'v1.9');
assert.equal(s.milestoneName, 'Code Quality');
});
test('parses phase without name', () => {
const content = [
'---',
'status: planning',
'---',
'Phase: 3 of 10',
].join('\n');
const s = parseStateMd(content);
assert.equal(s.phaseNum, '3');
assert.equal(s.phaseTotal, '10');
assert.equal(s.phaseName, null);
});
test('falls back to body Status when frontmatter is missing', () => {
const content = [
'# State',
'Status: Ready to plan',
].join('\n');
const s = parseStateMd(content);
assert.equal(s.status, 'planning');
});
test('body fallback recognizes executing state', () => {
const content = 'Status: Executing phase 2';
assert.equal(parseStateMd(content).status, 'executing');
});
test('body fallback recognizes complete state', () => {
const content = 'Status: Complete';
assert.equal(parseStateMd(content).status, 'complete');
});
test('body fallback recognizes archived as complete', () => {
const content = 'Status: Archived';
assert.equal(parseStateMd(content).status, 'complete');
});
test('returns empty object for empty content', () => {
const s = parseStateMd('');
assert.deepEqual(s, {});
});
test('returns partial state when only some fields present', () => {
const content = [
'---',
'milestone: v2.0',
'---',
].join('\n');
const s = parseStateMd(content);
assert.equal(s.milestone, 'v2.0');
assert.equal(s.status, undefined);
assert.equal(s.phaseNum, undefined);
});
test('parses next_phases from YAML block-list form (#3153)', () => {
const content = [
'---',
'next_action: execute',
'next_phases:',
' - 4.5',
' - 4.6',
'---',
].join('\n');
const s = parseStateMd(content);
assert.equal(s.nextAction, 'execute');
assert.deepEqual(s.nextPhases, ['4.5', '4.6']);
});
});
// ─── formatGsdState ─────────────────────────────────────────────────────────
describe('formatGsdState', () => {
test('formats full state with milestone name, status, and phase name', () => {
const out = formatGsdState({
milestone: 'v1.9',
milestoneName: 'Code Quality',
status: 'executing',
phaseNum: '1',
phaseTotal: '5',
phaseName: 'fix-graphiti-deployment',
});
assert.equal(out, 'v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)');
});
test('skips placeholder "milestone" value in milestoneName', () => {
const out = formatGsdState({
milestone: 'v1.0',
milestoneName: 'milestone',
status: 'planning',
});
assert.equal(out, 'v1.0 · planning');
});
test('uses short phase form when phase name is missing', () => {
const out = formatGsdState({
milestone: 'v2.0',
status: 'executing',
phaseNum: '3',
phaseTotal: '7',
});
assert.equal(out, 'v2.0 · executing · ph 3/7');
});
test('omits phase entirely when phaseNum/phaseTotal missing', () => {
const out = formatGsdState({
milestone: 'v1.0',
status: 'planning',
});
assert.equal(out, 'v1.0 · planning');
});
test('handles milestone version only (no name)', () => {
const out = formatGsdState({
milestone: 'v1.9',
status: 'executing',
});
assert.equal(out, 'v1.9 · executing');
});
test('handles milestone name only (no version)', () => {
const out = formatGsdState({
milestoneName: 'Foundations',
status: 'planning',
});
assert.equal(out, 'Foundations · planning');
});
test('treats numeric 100 percent as milestone complete (#3153)', () => {
const out = formatGsdState({
milestone: 'v2.0',
percent: 100,
});
assert.equal(out, 'v2.0 [██████████] 100% · milestone complete');
});
test('returns empty string for empty state', () => {
assert.equal(formatGsdState({}), '');
});
test('returns only available parts when everything else is missing', () => {
assert.equal(formatGsdState({ status: 'planning' }), 'planning');
});
});
describe('isInstalledAheadOfLatest', () => {
test('treats prerelease patch increment as ahead of prior stable', () => {
assert.equal(isInstalledAheadOfLatest('1.2.1-beta.1', '1.2.0'), true);
});
test('treats equal base version prerelease as not ahead', () => {
assert.equal(isInstalledAheadOfLatest('1.2.0-rc.1', '1.2.0'), false);
});
});
// ─── readGsdState ───────────────────────────────────────────────────────────
describe('readGsdState', () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-statusline-test-'));
test('finds STATE.md in the starting directory', () => {
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(proj, '.planning', 'STATE.md'),
'---\nstatus: executing\nmilestone: v1.0\n---\n'
);
const s = readGsdState(proj);
assert.equal(s.status, 'executing');
assert.equal(s.milestone, 'v1.0');
});
test('walks up to find STATE.md in a parent directory', () => {
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(proj, '.planning', 'STATE.md'),
'---\nstatus: planning\n---\n'
);
const nested = path.join(proj, 'src', 'components', 'deep');
fs.mkdirSync(nested, { recursive: true });
const s = readGsdState(nested);
assert.equal(s.status, 'planning');
});
test('returns null when no STATE.md exists in the walk-up chain', () => {
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
const nested = path.join(proj, 'src');
fs.mkdirSync(nested, { recursive: true });
assert.equal(readGsdState(nested), null);
});
test('returns null on malformed STATE.md without crashing', () => {
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true });
// Valid file (no content to crash on) — parseStateMd returns {}
fs.writeFileSync(path.join(proj, '.planning', 'STATE.md'), '');
const s = readGsdState(proj);
// Empty file yields an empty state object, not null — the function
// only returns null when no file is found.
assert.deepEqual(s, {});
});
});
// ─── CLAUDE_CODE_AUTO_COMPACT_WINDOW context meter (#2219) ──────────────────
describe('context meter respects CLAUDE_CODE_AUTO_COMPACT_WINDOW (#2219)', () => {
const { execFileSync } = require('node:child_process');
const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
/**
* Run the statusline hook with a synthetic context_window payload.
* Returns { normalizedUsed, rawUsedPct } where:
* - normalizedUsed: the buffer-adjusted % shown in the statusline bar
* (parsed from the hook's stdout ANSI output, e.g. "60%")
* - rawUsedPct: the raw value written to the bridge file (100 - remaining,
* CC-consistent per #2451 fix)
*/
function runHook(remainingPct, totalTokens, acwEnv) {
const sessionId = `test-2219-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const payload = JSON.stringify({
model: { display_name: 'Claude' },
workspace: { current_dir: os.tmpdir() },
session_id: sessionId,
context_window: {
remaining_percentage: remainingPct,
total_tokens: totalTokens,
},
});
const env = { ...process.env };
if (acwEnv != null) {
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv);
} else {
delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
}
let stdout = '';
try {
stdout = execFileSync(process.execPath, [hookPath], {
input: payload,
env,
encoding: 'utf8',
timeout: 4000,
});
} catch (e) {
stdout = e.stdout || '';
}
// Parse normalized used% from the statusline bar output (e.g. "60%")
// Strip ANSI escape codes then extract the percentage digit(s) before "%"
const clean = stdout.replace(/\x1b\[[0-9;]*m/g, '');
const match = clean.match(/(\d+)%/);
const normalizedUsed = match ? parseInt(match[1], 10) : null;
// Read raw used_pct from the bridge file (#2451: bridge stores raw CC value)
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
let rawUsedPct = null;
try {
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf8'));
rawUsedPct = bridge.used_pct;
fs.unlinkSync(bridgePath);
} catch { /* bridge may not exist if hook exited early */ }
return { normalizedUsed, rawUsedPct };
}
test('default buffer (no env var): 50% remaining → ~60% normalized bar display', () => {
// Default 16.5% buffer: usableRemaining = (50 - 16.5) / (100 - 16.5) * 100 ≈ 40.12%
// normalized used ≈ 100 - 40.12 = 59.88 → rounded 60 (shown in statusline bar)
const { normalizedUsed } = runHook(50, 1_000_000, null);
assert.strictEqual(normalizedUsed, 60);
});
test('CLAUDE_CODE_AUTO_COMPACT_WINDOW=400000: 50% remaining → ~83% normalized bar display', () => {
// With 1M total, 400k window → buffer = 40%. usableRemaining = (50 - 40) / (100 - 40) * 100 ≈ 16.67%
// normalized used ≈ 100 - 16.67 = 83.33 → rounded 83 (shown in statusline bar)
const { normalizedUsed } = runHook(50, 1_000_000, 400_000);
assert.strictEqual(normalizedUsed, 83);
});
test('CLAUDE_CODE_AUTO_COMPACT_WINDOW=0 falls back to default buffer', () => {
// Explicit "0" means unset — should behave like no env var (16.5% buffer)
const { normalizedUsed } = runHook(50, 1_000_000, 0);
assert.strictEqual(normalizedUsed, 60);
});
test('buffer capped at 100% when ACW exceeds total context', () => {
// Pathological: ACW > totalCtx → buffer = 100%. With no usable range left,
// usableRemaining = max(0, (50-100)/(100-100)*100) = max(0, -Inf) = 0,
// so normalized used = 100 (context reported as completely full in bar).
const { normalizedUsed } = runHook(50, 1_000_000, 2_000_000);
assert.strictEqual(normalizedUsed, 100);
});
test('bridge used_pct is raw (CC-consistent) regardless of ACW setting (#2451)', () => {
// Fix for #2451: bridge used_pct must be raw (100 - remaining), not normalized.
// This ensures gsd-context-monitor warning messages match CC native /context.
// The ACW normalization only affects the statusline bar display, not the bridge.
const { rawUsedPct } = runHook(50, 1_000_000, 400_000);
assert.strictEqual(rawUsedPct, 50,
'bridge used_pct must be raw (100-50=50) regardless of CLAUDE_CODE_AUTO_COMPACT_WINDOW');
});
});
// ─── todo-resolution path (#305) ────────────────────────────────────────────
describe('todo-resolution: resolves in_progress task from the newest matching todos file (#305)', () => {
const { execFileSync } = require('node:child_process');
const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
test('resolves in_progress task from the newest matching todos file (#305)', (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-305-'));
t.after(() => {
cleanup(tempDir);
});
const todosDir = path.join(tempDir, 'todos');
fs.mkdirSync(todosDir, { recursive: true });
const session = `sess-305-${Math.random().toString(36).slice(2)}`;
const now = Date.now() / 1000; // seconds for utimesSync
// Older matching file — should NOT be selected
const olderPath = path.join(todosDir, `${session}-agent-A.json`);
fs.writeFileSync(olderPath, JSON.stringify([
{ content: 'old task', status: 'in_progress', activeForm: 'OLDER TASK 305' },
]));
const olderTime = now - 10000;
fs.utimesSync(olderPath, olderTime, olderTime);
// Newer matching file — should be selected
const newerPath = path.join(todosDir, `${session}-agent-B.json`);
fs.writeFileSync(newerPath, JSON.stringify([
{ content: 'new task', status: 'in_progress', activeForm: 'NEWER TASK 305' },
]));
const newerTime = now - 1000;
fs.utimesSync(newerPath, newerTime, newerTime);
// Distractor: different session prefix — must be ignored even with very-new mtime
const wrongSessPath = path.join(todosDir, 'other-sess-agent-Z.json');
fs.writeFileSync(wrongSessPath, JSON.stringify([
{ content: 'wrong session', status: 'in_progress', activeForm: 'WRONG SESSION 305' },
]));
fs.utimesSync(wrongSessPath, now, now);
// Distractor: matches session + .json but lacks -agent- — must be ignored
const notAgentPath = path.join(todosDir, `${session}-notagent.json`);
fs.writeFileSync(notAgentPath, JSON.stringify([
{ content: 'not agent', status: 'in_progress', activeForm: 'NOT AGENT 305' },
]));
fs.utimesSync(notAgentPath, now, now);
const payload = JSON.stringify({
model: { display_name: 'Claude' },
workspace: { current_dir: os.tmpdir() },
session_id: session,
context_window: { remaining_percentage: 80, total_tokens: 1_000_000 },
});
const env = { ...process.env, CLAUDE_CONFIG_DIR: tempDir };
let stdout = '';
try {
stdout = execFileSync(process.execPath, [hookPath], {
input: payload,
env,
encoding: 'utf8',
timeout: 4000,
});
} catch (e) {
stdout = e.stdout || '';
}
assert.ok(stdout.includes('NEWER TASK 305'),
`expected stdout to contain "NEWER TASK 305", got: ${stdout}`);
assert.ok(!stdout.includes('OLDER TASK 305'),
`stdout must NOT contain "OLDER TASK 305", got: ${stdout}`);
assert.ok(!stdout.includes('WRONG SESSION 305'),
`stdout must NOT contain "WRONG SESSION 305", got: ${stdout}`);
assert.ok(!stdout.includes('NOT AGENT 305'),
`stdout must NOT contain "NOT AGENT 305", got: ${stdout}`);
});
});