test: add regression harness for auto-mode dispatch loop (125 assertions) (#1319)
This commit is contained in:
parent
805c7718c4
commit
68e0672dda
4 changed files with 1582 additions and 0 deletions
691
src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts
Normal file
691
src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
/**
|
||||
* auto-dispatch-loop.test.ts — End-to-end regression tests for the
|
||||
* auto-mode dispatch loop: deriveState() → resolveDispatch()
|
||||
*
|
||||
* Exercises the full state-machine chain WITHOUT an LLM. Each test
|
||||
* creates a .gsd/ filesystem fixture, derives state, runs the dispatch
|
||||
* table, and verifies the correct unit type/id is produced.
|
||||
*
|
||||
* Regression coverage for:
|
||||
* #1270 Replaying completed run-uat units
|
||||
* #1277 Non-artifact UATs dispatched, blocking progression
|
||||
* #1241 Slice progression gated on file existence, not verdict content
|
||||
* #909 Missing task plan files → infinite plan-slice loop
|
||||
* #807 Prose slice headers not parsed → "No slice eligible" block
|
||||
* #1248 Prose header regex only matched H2 with colon separator
|
||||
* #1289 Crash recovery false-positive on own PID
|
||||
* #1217 (orphaned processes — tested via post-unit, not dispatch)
|
||||
*
|
||||
* Pattern: create fixture → deriveState → resolveDispatch → assert
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { deriveState, invalidateStateCache } from '../state.ts';
|
||||
import { resolveDispatch, type DispatchContext } from '../auto-dispatch.ts';
|
||||
import { parseRoadmapSlices } from '../roadmap-slices.ts';
|
||||
import { checkNeedsRunUat } from '../auto-prompts.ts';
|
||||
import { checkIdempotency, type IdempotencyContext } from '../auto-idempotency.ts';
|
||||
import { invalidateAllCaches } from '../cache.ts';
|
||||
import { AutoSession } from '../auto/session.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Fixture Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-dispatch-loop-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${mid}-${suffix}.md`), content);
|
||||
}
|
||||
|
||||
function writeSliceFile(base: string, mid: string, sid: string, suffix: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${sid}-${suffix}.md`), content);
|
||||
}
|
||||
|
||||
function writeTaskFile(base: string, mid: string, sid: string, tid: string, suffix: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid, 'tasks');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${tid}-${suffix}.md`), content);
|
||||
}
|
||||
|
||||
/** Standard machine-readable roadmap with checkbox slices */
|
||||
function standardRoadmap(mid: string, title: string, slices: Array<{ id: string; title: string; done: boolean; risk?: string; depends?: string[] }>): string {
|
||||
const lines = [
|
||||
`# ${mid}: ${title}`,
|
||||
'',
|
||||
'## Slices',
|
||||
'',
|
||||
];
|
||||
for (const s of slices) {
|
||||
const check = s.done ? 'x' : ' ';
|
||||
const risk = s.risk ?? 'low';
|
||||
const deps = s.depends ?? [];
|
||||
lines.push(`- [${check}] **${s.id}: ${s.title}** \`risk:${risk}\` \`depends:[${deps.join(',')}]\``);
|
||||
}
|
||||
lines.push('', '## Boundary Map', '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Standard slice plan with tasks */
|
||||
function standardPlan(sid: string, title: string, tasks: Array<{ id: string; title: string; done: boolean; est?: string }>): string {
|
||||
const lines = [
|
||||
`# ${sid}: ${title}`,
|
||||
'',
|
||||
'## Tasks',
|
||||
'',
|
||||
];
|
||||
for (const t of tasks) {
|
||||
const check = t.done ? 'x' : ' ';
|
||||
const est = t.est ?? '1h';
|
||||
lines.push(`- [${check}] **${t.id}: ${t.title}** \`est:${est}\``);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function freshState(): void {
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
}
|
||||
|
||||
async function dispatchFor(base: string): Promise<ReturnType<typeof resolveDispatch>> {
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
const mid = state.activeMilestone?.id;
|
||||
if (!mid) return { action: 'stop', reason: 'No active milestone', level: 'info' };
|
||||
const midTitle = state.activeMilestone?.title ?? mid;
|
||||
const ctx: DispatchContext = { basePath: base, mid, midTitle, state, prefs: undefined };
|
||||
return resolveDispatch(ctx);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── 1. Basic state derivation: pre-planning → plan-milestone ─────────
|
||||
console.log('\n=== 1. pre-planning with context → plan-milestone (or research) ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test Project\n\nBuild a thing.\n');
|
||||
const result = await dispatchFor(base);
|
||||
assertTrue(
|
||||
result.action === 'dispatch',
|
||||
'pre-planning with context dispatches a unit',
|
||||
);
|
||||
if (result.action === 'dispatch') {
|
||||
assertTrue(
|
||||
result.unitType === 'research-milestone' || result.unitType === 'plan-milestone',
|
||||
`dispatches research-milestone or plan-milestone, got ${result.unitType}`,
|
||||
);
|
||||
assertEq(result.unitId, 'M001', 'unit ID is M001');
|
||||
}
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. Planning → plan-slice ─────────────────────────────────────────
|
||||
console.log('\n=== 2. has roadmap, no slice plan → plan-slice ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'First Slice', done: false },
|
||||
{ id: 'S02', title: 'Second Slice', done: false, depends: ['S01'] },
|
||||
]));
|
||||
const result = await dispatchFor(base);
|
||||
assertTrue(result.action === 'dispatch', 'planning phase dispatches');
|
||||
if (result.action === 'dispatch') {
|
||||
assertTrue(
|
||||
result.unitType === 'plan-slice' || result.unitType === 'research-slice',
|
||||
`dispatches plan-slice or research-slice, got ${result.unitType}`,
|
||||
);
|
||||
assertMatch(result.unitId, /M001\/S01/, 'targets S01');
|
||||
}
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3. Executing → execute-task ──────────────────────────────────────
|
||||
console.log('\n=== 3. has plan with incomplete task → execute-task ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'First Slice', done: false },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
|
||||
{ id: 'T01', title: 'First Task', done: false },
|
||||
{ id: 'T02', title: 'Second Task', done: false },
|
||||
]));
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01: First Task\n\nDo the thing.\n');
|
||||
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02: Second Task\n\nDo more.\n');
|
||||
|
||||
const result = await dispatchFor(base);
|
||||
assertTrue(result.action === 'dispatch', 'executing phase dispatches');
|
||||
if (result.action === 'dispatch') {
|
||||
assertEq(result.unitType, 'execute-task', 'dispatches execute-task');
|
||||
assertEq(result.unitId, 'M001/S01/T01', 'targets T01');
|
||||
}
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. All tasks done → complete-slice (summarizing) ─────────────────
|
||||
console.log('\n=== 4. all tasks done → summarizing → complete-slice ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'First Slice', done: false },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
|
||||
{ id: 'T01', title: 'First Task', done: true },
|
||||
{ id: 'T02', title: 'Second Task', done: true },
|
||||
]));
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDone.');
|
||||
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDone.');
|
||||
|
||||
const result = await dispatchFor(base);
|
||||
assertTrue(result.action === 'dispatch', 'summarizing phase dispatches');
|
||||
if (result.action === 'dispatch') {
|
||||
assertEq(result.unitType, 'complete-slice', 'dispatches complete-slice');
|
||||
assertEq(result.unitId, 'M001/S01', 'targets S01');
|
||||
}
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 5. Regression #909: Missing task plan files → plan-slice ─────────
|
||||
console.log('\n=== 5. #909: tasks in plan but empty tasks/ dir → plan-slice (not stuck loop) ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
// Add milestone research so research-slice doesn't fire first
|
||||
writeMilestoneFile(base, 'M001', 'RESEARCH', '# Research\n\nDone.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'First Slice', done: false },
|
||||
]));
|
||||
// Also write slice research so research-slice is skipped
|
||||
writeSliceFile(base, 'M001', 'S01', 'RESEARCH', '# Slice Research\n\nDone.\n');
|
||||
// Plan references tasks but tasks/ dir has no files
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
|
||||
{ id: 'T01', title: 'First Task', done: false },
|
||||
]));
|
||||
// Create empty tasks directory (no task plan files)
|
||||
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'), { recursive: true });
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
// Should fall back to planning phase since tasks dir is empty
|
||||
assertEq(state.phase, 'planning', '#909: empty tasks dir → planning phase (not executing)');
|
||||
|
||||
const result = await dispatchFor(base);
|
||||
assertTrue(result.action === 'dispatch', '#909: dispatches');
|
||||
if (result.action === 'dispatch') {
|
||||
assertEq(result.unitType, 'plan-slice', '#909: dispatches plan-slice to regenerate task plans');
|
||||
}
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. Regression #1277: Non-artifact UAT not dispatched ─────────────
|
||||
console.log('\n=== 6. #1277: human-experience UAT → null (skip, not dispatch) ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Done Slice', done: true },
|
||||
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: human-experience\n');
|
||||
|
||||
const state = {
|
||||
activeMilestone: { id: 'M001', title: 'Test' },
|
||||
activeSlice: { id: 'S02', title: 'Next Slice' },
|
||||
activeTask: null,
|
||||
phase: 'planning',
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: 'Plan S02',
|
||||
registry: [],
|
||||
};
|
||||
|
||||
freshState();
|
||||
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
|
||||
assertEq(result, null, '#1277: human-experience UAT returns null (not dispatched)');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 7. Regression #1277: artifact-driven UAT without result → dispatch ──
|
||||
console.log('\n=== 7. artifact-driven UAT without result → dispatch ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Done Slice', done: true },
|
||||
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
|
||||
// No UAT-RESULT file
|
||||
|
||||
const state = {
|
||||
activeMilestone: { id: 'M001', title: 'Test' },
|
||||
activeSlice: { id: 'S02', title: 'Next Slice' },
|
||||
activeTask: null,
|
||||
phase: 'planning',
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: 'Plan S02',
|
||||
registry: [],
|
||||
};
|
||||
|
||||
freshState();
|
||||
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
|
||||
assertTrue(result !== null, 'artifact-driven UAT without result → dispatch (not null)');
|
||||
if (result) {
|
||||
assertEq(result.sliceId, 'S01', 'targets S01');
|
||||
}
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 8. Regression #1270: Existing UAT-RESULT never re-dispatches ─────
|
||||
console.log('\n=== 8. #1270: UAT-RESULT exists → no re-dispatch (any verdict) ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Done Slice', done: true },
|
||||
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\nFailed.\n');
|
||||
|
||||
const state = {
|
||||
activeMilestone: { id: 'M001', title: 'Test' },
|
||||
activeSlice: { id: 'S02', title: 'Next Slice' },
|
||||
activeTask: null,
|
||||
phase: 'planning',
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: 'Plan S02',
|
||||
registry: [],
|
||||
};
|
||||
|
||||
freshState();
|
||||
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
|
||||
assertEq(result, null, '#1270: existing UAT-RESULT with FAIL → null (no re-dispatch)');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 9. Regression #1241: UAT verdict gate blocks non-PASS ────────────
|
||||
console.log('\n=== 9. #1241: UAT verdict gate blocks progression on non-PASS verdict ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Done Slice', done: true },
|
||||
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Done Slice', [
|
||||
{ id: 'T01', title: 'Task', done: true },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\nFailed some check.\n');
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
const ctx: DispatchContext = {
|
||||
basePath: base,
|
||||
mid: 'M001',
|
||||
midTitle: 'Test',
|
||||
state,
|
||||
prefs: { uat_dispatch: true } as any,
|
||||
};
|
||||
const result = await resolveDispatch(ctx);
|
||||
// The uat-verdict-gate rule should stop progression
|
||||
assertEq(result.action, 'stop', '#1241: non-PASS verdict → stop (blocks progression)');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 10. #1241: UAT verdict PASS allows progression ───────────────────
|
||||
console.log('\n=== 10. UAT verdict PASS → allows progression ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Done Slice', done: true },
|
||||
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: PASS\n---\nAll good.\n');
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
const ctx: DispatchContext = {
|
||||
basePath: base,
|
||||
mid: 'M001',
|
||||
midTitle: 'Test',
|
||||
state,
|
||||
prefs: { uat_dispatch: true } as any,
|
||||
};
|
||||
const result = await resolveDispatch(ctx);
|
||||
// PASS verdict should NOT block — dispatch should continue to plan-slice for S02
|
||||
assertTrue(result.action !== 'stop' || !('reason' in result && result.reason.includes('verdict')), 'PASS verdict does not block progression');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 11. Complete state derivation: all slices done → completing ───────
|
||||
console.log('\n=== 11. all slices done, no validation → validating-milestone ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'First Slice', done: true },
|
||||
]));
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'validating-milestone', 'all slices done → validating-milestone');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 12. Complete milestone → complete phase ──────────────────────────
|
||||
console.log('\n=== 12. validated + summarized milestone → complete ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'First Slice', done: true },
|
||||
]));
|
||||
writeMilestoneFile(base, 'M001', 'VALIDATION', '---\nverdict: pass\nremediation_round: 0\n---\n# Validation\nAll good.\n');
|
||||
writeMilestoneFile(base, 'M001', 'SUMMARY', '---\nstatus: complete\n---\n# Summary\nDone.\n');
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'complete', 'validated+summarized → complete');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 13. Multi-milestone: M001 complete, M002 active ─────────────────
|
||||
console.log('\n=== 13. multi-milestone: M001 complete, M002 becomes active ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
// M001 — complete
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDone.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'First', [
|
||||
{ id: 'S01', title: 'Slice', done: true },
|
||||
]));
|
||||
writeMilestoneFile(base, 'M001', 'VALIDATION', '---\nverdict: pass\nremediation_round: 0\n---\n');
|
||||
writeMilestoneFile(base, 'M001', 'SUMMARY', '---\nstatus: complete\n---\n# Summary\n');
|
||||
|
||||
// M002 — active
|
||||
writeMilestoneFile(base, 'M002', 'CONTEXT', '# M002\n\nNext.\n');
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M002', 'M002 is the active milestone');
|
||||
assertEq(state.phase, 'pre-planning', 'M002 is in pre-planning');
|
||||
assertEq(state.registry.length, 2, 'registry has 2 milestones');
|
||||
assertEq(state.registry[0].status, 'complete', 'M001 is complete');
|
||||
assertEq(state.registry[1].status, 'active', 'M002 is active');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 14. Dependency blocking: S02 depends on S01 ─────────────────────
|
||||
console.log('\n=== 14. slice dependency: S02 blocked until S01 done ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'First', done: false },
|
||||
{ id: 'S02', title: 'Second', done: false, depends: ['S01'] },
|
||||
]));
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
// Active slice should be S01, not S02
|
||||
assertEq(state.activeSlice?.id, 'S01', 'S01 is the active slice (S02 is dep-blocked)');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 15. Blocker detection: task with blocker_discovered → replan ─────
|
||||
console.log('\n=== 15. blocker_discovered in task summary → replanning-slice ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Slice', done: false },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
|
||||
{ id: 'T01', title: 'Task One', done: true },
|
||||
{ id: 'T02', title: 'Task Two', done: false },
|
||||
]));
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
|
||||
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDo other thing.');
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nblocker_discovered: true\n---\n# T01 Summary\nFound a blocker.');
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'replanning-slice', 'blocker_discovered → replanning-slice');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 16. Blocker + REPLAN exists → loop protection, resume executing ──
|
||||
console.log('\n=== 16. blocker_discovered + REPLAN exists → loop protection (executing) ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Slice', done: false },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
|
||||
{ id: 'T01', title: 'Task One', done: true },
|
||||
{ id: 'T02', title: 'Task Two', done: false },
|
||||
]));
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
|
||||
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDo other thing.');
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nblocker_discovered: true\n---\n# T01\nBlocker.');
|
||||
// REPLAN.md exists → loop protection
|
||||
writeSliceFile(base, 'M001', 'S01', 'REPLAN', '# Replan\nAlready replanned.\n');
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'executing', 'blocker + REPLAN exists → executing (loop protection)');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 17. Needs-discussion phase ───────────────────────────────────────
|
||||
console.log('\n=== 17. CONTEXT-DRAFT without CONTEXT → needs-discussion ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
const mDir = join(base, '.gsd', 'milestones', 'M001');
|
||||
mkdirSync(mDir, { recursive: true });
|
||||
writeFileSync(join(mDir, 'M001-CONTEXT-DRAFT.md'), '# Draft\n\nSome rough ideas.\n');
|
||||
|
||||
freshState();
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'needs-discussion', 'CONTEXT-DRAFT without CONTEXT → needs-discussion');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 18. Idempotency: completed key → skip ───────────────────────────
|
||||
console.log('\n=== 18. idempotency: completed key → skip ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Slice', done: false },
|
||||
]));
|
||||
// Task must be marked [x] in the plan for verifyExpectedArtifact to return true
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
|
||||
{ id: 'T01', title: 'Task', done: true },
|
||||
{ id: 'T02', title: 'Next Task', done: false },
|
||||
]));
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
|
||||
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nNext.');
|
||||
// Write SUMMARY as the expected artifact for execute-task
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nstatus: done\n---\n# T01 Summary\nDone.');
|
||||
|
||||
// Force cache clearance so verifyExpectedArtifact finds the file
|
||||
freshState();
|
||||
|
||||
const session = new AutoSession();
|
||||
session.basePath = base;
|
||||
session.completedKeySet.add('execute-task/M001/S01/T01');
|
||||
|
||||
const notifications: string[] = [];
|
||||
const result = checkIdempotency({
|
||||
s: session,
|
||||
unitType: 'execute-task',
|
||||
unitId: 'M001/S01/T01',
|
||||
basePath: base,
|
||||
notify: (msg) => notifications.push(msg),
|
||||
});
|
||||
|
||||
assertEq(result.action, 'skip', 'completed key → skip');
|
||||
assertTrue('reason' in result && result.reason === 'completed', 'reason is completed');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 19. Idempotency: stale key (artifact missing) → rerun ───────────
|
||||
console.log('\n=== 19. idempotency: stale key (no artifact) → rerun ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
|
||||
{ id: 'S01', title: 'Slice', done: false },
|
||||
]));
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
|
||||
{ id: 'T01', title: 'Task', done: false },
|
||||
]));
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
|
||||
// NO summary file — artifact missing
|
||||
|
||||
const session = new AutoSession();
|
||||
session.basePath = base;
|
||||
session.completedKeySet.add('execute-task/M001/S01/T01');
|
||||
|
||||
const notifications: string[] = [];
|
||||
const result = checkIdempotency({
|
||||
s: session,
|
||||
unitType: 'execute-task',
|
||||
unitId: 'M001/S01/T01',
|
||||
basePath: base,
|
||||
notify: (msg) => notifications.push(msg),
|
||||
});
|
||||
|
||||
assertEq(result.action, 'rerun', 'stale key (no artifact) → rerun');
|
||||
assertTrue(!session.completedKeySet.has('execute-task/M001/S01/T01'), 'stale key removed from set');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 20. Idempotency: consecutive skip loop → evict ──────────────────
|
||||
console.log('\n=== 20. idempotency: consecutive skip loop → evict ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
|
||||
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nstatus: done\n---\n# Done');
|
||||
|
||||
const session = new AutoSession();
|
||||
session.basePath = base;
|
||||
session.completedKeySet.add('execute-task/M001/S01/T01');
|
||||
// Pre-fill skip count to just below threshold
|
||||
session.unitConsecutiveSkips.set('execute-task/M001/S01/T01', 3);
|
||||
|
||||
const notifications: string[] = [];
|
||||
const result = checkIdempotency({
|
||||
s: session,
|
||||
unitType: 'execute-task',
|
||||
unitId: 'M001/S01/T01',
|
||||
basePath: base,
|
||||
notify: (msg) => notifications.push(msg),
|
||||
});
|
||||
|
||||
assertEq(result.action, 'skip', 'exceeds consecutive skip threshold → skip with eviction');
|
||||
assertTrue('reason' in result && result.reason === 'evicted', 'reason is evicted');
|
||||
assertTrue(!session.completedKeySet.has('execute-task/M001/S01/T01'), 'key evicted from completed set');
|
||||
assertTrue(session.recentlyEvictedKeys.has('execute-task/M001/S01/T01'), 'key tracked in evicted set');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
/**
|
||||
* cache-staleness-regression.test.ts — Regression tests for stale cache bugs.
|
||||
*
|
||||
* The GSD parser caches are critical for performance but have caused multiple
|
||||
* production bugs when not invalidated at the right time.
|
||||
*
|
||||
* Regression coverage for:
|
||||
* #1249 Stale caches in discuss loop → slice appears "not discussed"
|
||||
* #1240 Stale caches after milestone creation → "No roadmap yet"
|
||||
* #1236 Same root cause as #1240
|
||||
*
|
||||
* Pattern: derive state → write file → invalidate cache → derive again → verify update
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { deriveState, invalidateStateCache } from '../state.ts';
|
||||
import { invalidateAllCaches } from '../cache.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
function createBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-cache-stale-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${mid}-${suffix}.md`), content);
|
||||
}
|
||||
|
||||
function writeSliceFile(base: string, mid: string, sid: string, suffix: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${sid}-${suffix}.md`), content);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── 1. Regression #1240: New roadmap detected after cache invalidation ─
|
||||
console.log('\n=== 1. #1240: roadmap written after first derive → detected after invalidation ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
// Step 1: Create milestone with just context (no roadmap)
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nBuild a thing.\n');
|
||||
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state1 = await deriveState(base);
|
||||
assertEq(state1.phase, 'pre-planning', 'initial: pre-planning (no roadmap)');
|
||||
|
||||
// Step 2: Write roadmap (simulating what the LLM does during planning)
|
||||
const roadmap = [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'## Slices',
|
||||
'',
|
||||
'- [ ] **S01: First Slice** `risk:low` `depends:[]`',
|
||||
'',
|
||||
'## Boundary Map',
|
||||
'',
|
||||
].join('\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', roadmap);
|
||||
|
||||
// Step 3: WITHOUT invalidation, the old state might be cached
|
||||
// The state cache has a 100ms TTL, so wait just past it
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
|
||||
// Step 4: Invalidate and re-derive — should see the new roadmap
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state2 = await deriveState(base);
|
||||
assertEq(state2.phase, 'planning', '#1240: after roadmap write + invalidation → planning phase');
|
||||
assertEq(state2.activeSlice?.id, 'S01', '#1240: S01 is now the active slice');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. Regression #1249: Slice context detected after cache invalidation ─
|
||||
console.log('\n=== 2. #1249: slice context written mid-loop → detected after invalidation ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
// Create a milestone in needs-discussion phase (CONTEXT-DRAFT, no CONTEXT)
|
||||
const mDir = join(base, '.gsd', 'milestones', 'M001');
|
||||
mkdirSync(mDir, { recursive: true });
|
||||
writeFileSync(join(mDir, 'M001-CONTEXT-DRAFT.md'), '# Draft\n\nSome ideas.\n');
|
||||
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state1 = await deriveState(base);
|
||||
assertEq(state1.phase, 'needs-discussion', 'initial: needs-discussion');
|
||||
|
||||
// Simulate: discussion completes, CONTEXT.md is written
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nFull context after discussion.\n');
|
||||
|
||||
// Wait past TTL
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
|
||||
// Without invalidation, we'd still see 'needs-discussion'
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state2 = await deriveState(base);
|
||||
// Should now be pre-planning (has context, but no roadmap yet)
|
||||
// Actually needs-discussion won't trigger because now CONTEXT exists
|
||||
// The state should advance past needs-discussion
|
||||
assertTrue(
|
||||
state2.phase !== 'needs-discussion',
|
||||
'#1249: after context write + invalidation → not stuck in needs-discussion',
|
||||
);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3. State cache TTL expires naturally ─────────────────────────────
|
||||
console.log('\n=== 3. state cache TTL: fresh reads after 100ms ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state1 = await deriveState(base);
|
||||
assertEq(state1.phase, 'pre-planning', 'initial: pre-planning');
|
||||
|
||||
// Write roadmap immediately
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'## Slices',
|
||||
'',
|
||||
'- [ ] **S01: Slice** `risk:low` `depends:[]`',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
// Immediately after writing (within 100ms TTL), the cache might be stale
|
||||
const state2 = await deriveState(base);
|
||||
// This MAY still show pre-planning if within TTL — that's expected behavior
|
||||
|
||||
// Wait past TTL
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
|
||||
// ALSO invalidate parse cache (not just state cache)
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state3 = await deriveState(base);
|
||||
assertEq(state3.phase, 'planning', 'after TTL expiry + invalidation → planning');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. Task completion detection after file write ────────────────────
|
||||
console.log('\n=== 4. task marked done in plan → state advances ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'## Slices',
|
||||
'',
|
||||
'- [ ] **S01: Slice** `risk:low` `depends:[]`',
|
||||
'',
|
||||
].join('\n'));
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
|
||||
'# S01: Slice',
|
||||
'',
|
||||
'## Tasks',
|
||||
'',
|
||||
'- [ ] **T01: First Task** `est:1h`',
|
||||
'- [ ] **T02: Second Task** `est:1h`',
|
||||
].join('\n'));
|
||||
// Write task plan files
|
||||
const tasksDir = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'T01-PLAN.md'), '# T01\nDo thing.');
|
||||
writeFileSync(join(tasksDir, 'T02-PLAN.md'), '# T02\nDo other thing.');
|
||||
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state1 = await deriveState(base);
|
||||
assertEq(state1.activeTask?.id, 'T01', 'initial: T01 is active task');
|
||||
|
||||
// Mark T01 as done by rewriting the plan
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
|
||||
'# S01: Slice',
|
||||
'',
|
||||
'## Tasks',
|
||||
'',
|
||||
'- [x] **T01: First Task** `est:1h`',
|
||||
'- [ ] **T02: Second Task** `est:1h`',
|
||||
].join('\n'));
|
||||
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state2 = await deriveState(base);
|
||||
assertEq(state2.activeTask?.id, 'T02', 'after T01 done → T02 is active task');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 5. Slice completion detection ────────────────────────────────────
|
||||
console.log('\n=== 5. all tasks done → summarizing phase ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'## Slices',
|
||||
'',
|
||||
'- [ ] **S01: First** `risk:low` `depends:[]`',
|
||||
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
|
||||
'',
|
||||
].join('\n'));
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
|
||||
'# S01',
|
||||
'',
|
||||
'## Tasks',
|
||||
'',
|
||||
'- [ ] **T01: Task** `est:1h`',
|
||||
].join('\n'));
|
||||
const tasksDir = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'T01-PLAN.md'), '# T01\nDo it.');
|
||||
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state1 = await deriveState(base);
|
||||
assertEq(state1.phase, 'executing', 'initial: executing');
|
||||
|
||||
// Mark task done
|
||||
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
|
||||
'# S01',
|
||||
'',
|
||||
'## Tasks',
|
||||
'',
|
||||
'- [x] **T01: Task** `est:1h`',
|
||||
].join('\n'));
|
||||
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state2 = await deriveState(base);
|
||||
assertEq(state2.phase, 'summarizing', 'after all tasks done → summarizing');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. Roadmap slice marked done → advance to next slice ─────────────
|
||||
console.log('\n=== 6. roadmap slice marked [x] → next slice active ===');
|
||||
{
|
||||
const base = createBase();
|
||||
try {
|
||||
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'## Slices',
|
||||
'',
|
||||
'- [ ] **S01: First** `risk:low` `depends:[]`',
|
||||
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state1 = await deriveState(base);
|
||||
assertEq(state1.activeSlice?.id, 'S01', 'initial: S01 active');
|
||||
|
||||
// Mark S01 as done in roadmap
|
||||
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'## Slices',
|
||||
'',
|
||||
'- [x] **S01: First** `risk:low` `depends:[]`',
|
||||
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
invalidateAllCaches();
|
||||
invalidateStateCache();
|
||||
const state2 = await deriveState(base);
|
||||
assertEq(state2.activeSlice?.id, 'S02', 'after S01 done → S02 active');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* roadmap-parse-regression.test.ts — Regression tests for roadmap parsing.
|
||||
*
|
||||
* Exercises parseRoadmapSlices() and the prose fallback parser against
|
||||
* every known LLM-generated roadmap variant that has caused production bugs.
|
||||
*
|
||||
* Regression coverage for:
|
||||
* #807 Prose slice headers not parsed → "No slice eligible" block
|
||||
* #1248 Prose header regex only matched H2 with colon separator
|
||||
* #1243 Same root cause as #1248
|
||||
*
|
||||
* Also covers dependency expansion (range syntax) and edge cases.
|
||||
*/
|
||||
|
||||
import { parseRoadmapSlices, expandDependencies } from '../roadmap-slices.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// A. Standard machine-readable format (should always work)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== A. Standard checkbox format ===');
|
||||
|
||||
{
|
||||
const content = [
|
||||
'# M001: Test Project',
|
||||
'',
|
||||
'## Slices',
|
||||
'',
|
||||
'- [ ] **S01: First Slice** `risk:low` `depends:[]`',
|
||||
'- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`',
|
||||
'- [x] **S03: Third Slice** `risk:high` `depends:[S01,S02]`',
|
||||
'',
|
||||
'## Boundary Map',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 3, 'standard format: 3 slices');
|
||||
assertEq(slices[0].id, 'S01', 'S01 id');
|
||||
assertEq(slices[0].title, 'First Slice', 'S01 title');
|
||||
assertEq(slices[0].done, false, 'S01 not done');
|
||||
assertEq(slices[0].risk, 'low', 'S01 risk');
|
||||
assertEq(slices[0].depends.length, 0, 'S01 no deps');
|
||||
|
||||
assertEq(slices[1].id, 'S02', 'S02 id');
|
||||
assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
|
||||
assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
|
||||
|
||||
assertEq(slices[2].id, 'S03', 'S03 id');
|
||||
assertEq(slices[2].done, true, 'S03 is done');
|
||||
assertEq(slices[2].risk, 'high', 'S03 risk');
|
||||
assertEq(slices[2].depends.length, 2, 'S03 has 2 deps');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// B. Prose fallback: H2 with colon (the only format the old regex matched)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== B. Prose fallback: H2 with colon ===');
|
||||
|
||||
{
|
||||
const content = [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'## S01: Setup Foundation',
|
||||
'',
|
||||
'Do the setup work.',
|
||||
'',
|
||||
'## S02: Core Features',
|
||||
'',
|
||||
'Build the features.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'prose H2 colon: 2 slices');
|
||||
assertEq(slices[0].id, 'S01', 'S01 id');
|
||||
assertEq(slices[0].title, 'Setup Foundation', 'S01 title');
|
||||
assertEq(slices[1].id, 'S02', 'S02 id');
|
||||
assertEq(slices[1].title, 'Core Features', 'S02 title');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// C. Regression #1248: H3 headers (the old regex only matched ##)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== C. #1248: H3 headers ===');
|
||||
|
||||
{
|
||||
const content = [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'### S01: Setup Foundation',
|
||||
'',
|
||||
'Do the setup work.',
|
||||
'',
|
||||
'### S02: Core Features',
|
||||
'',
|
||||
'Build the features.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, '#1248 H3: 2 slices parsed');
|
||||
assertEq(slices[0].id, 'S01', 'S01 from H3');
|
||||
assertEq(slices[1].id, 'S02', 'S02 from H3');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// D. Regression #1248: H4 headers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== D. #1248: H4 headers ===');
|
||||
|
||||
{
|
||||
const content = [
|
||||
'# M001: Test',
|
||||
'',
|
||||
'#### S01: Setup Foundation',
|
||||
'',
|
||||
'#### S02: Core Features',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, '#1248 H4: 2 slices parsed');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// E. Regression #1248: H1 header (unusual but LLMs produce it)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E. #1248: H1 headers ===');
|
||||
|
||||
{
|
||||
const content = [
|
||||
'# S01: Setup Foundation',
|
||||
'',
|
||||
'Setup stuff.',
|
||||
'',
|
||||
'# S02: Core Features',
|
||||
'',
|
||||
'Build stuff.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, '#1248 H1: 2 slices parsed');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// F. Regression #1248: Bold-wrapped IDs
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== F. #1248: Bold-wrapped ===');
|
||||
|
||||
{
|
||||
const content1 = '## **S01: Setup Foundation**\n\nDo stuff.\n\n## **S02: Features**\n\nMore stuff.\n';
|
||||
const slices1 = parseRoadmapSlices(content1);
|
||||
assertEq(slices1.length, 2, 'bold-wrapped: 2 slices');
|
||||
assertEq(slices1[0].title, 'Setup Foundation', 'bold-wrapped: title extracted without bold');
|
||||
|
||||
const content2 = '## **S01**: Setup Foundation\n\n## **S02**: Features\n';
|
||||
const slices2 = parseRoadmapSlices(content2);
|
||||
assertEq(slices2.length, 2, 'bold ID only: 2 slices');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// G. Regression #1248: Dot separator
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== G. #1248: Dot separator ===');
|
||||
|
||||
{
|
||||
const content = '## S01. Setup Foundation\n\n## S02. Core Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'dot separator: 2 slices');
|
||||
assertEq(slices[0].title, 'Setup Foundation', 'dot separator: title');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// H. Regression #1248: Em dash separator
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== H. #1248: Em/en dash separators ===');
|
||||
|
||||
{
|
||||
const content = '## S01 — Setup Foundation\n\n## S02 – Core Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'em/en dash: 2 slices');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// I. Regression #1248: Space-only separator (no punctuation)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== I. #1248: Space-only separator ===');
|
||||
|
||||
{
|
||||
const content = '## S01 Setup Foundation\n\n## S02 Core Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'space-only: 2 slices');
|
||||
assertEq(slices[0].title, 'Setup Foundation', 'space-only: title');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// J. Regression #1248: Non-zero-padded IDs
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== J. #1248: Non-zero-padded IDs ===');
|
||||
|
||||
{
|
||||
const content = '## S1: Setup\n\n## S2: Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'non-padded: 2 slices');
|
||||
assertEq(slices[0].id, 'S1', 'non-padded: S1');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// K. Regression #1248: "Slice" prefix
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== K. #1248: "Slice" prefix ===');
|
||||
|
||||
{
|
||||
const content = '## Slice S01: Setup Foundation\n\n## Slice S02: Core Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'Slice prefix: 2 slices');
|
||||
assertEq(slices[0].id, 'S01', 'Slice prefix: S01');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// L. Prose with "Depends on:" line
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== L. Prose with Depends on: ===');
|
||||
|
||||
{
|
||||
const content = [
|
||||
'## S01: Foundation',
|
||||
'',
|
||||
'Build the base.',
|
||||
'',
|
||||
'## S02: Features',
|
||||
'',
|
||||
'**Depends on:** S01',
|
||||
'',
|
||||
'Build features.',
|
||||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'prose deps: 2 slices');
|
||||
assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
|
||||
assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// M. Empty / edge cases
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== M. Edge cases ===');
|
||||
|
||||
{
|
||||
assertEq(parseRoadmapSlices('').length, 0, 'empty content → 0 slices');
|
||||
assertEq(parseRoadmapSlices('# Just a title\n\nSome text.').length, 0, 'no slices at all → 0');
|
||||
|
||||
// Mixed format: ## Slices section with one checkbox + prose below
|
||||
const mixed = [
|
||||
'## Slices',
|
||||
'',
|
||||
'- [ ] **S01: Foundation** `risk:low` `depends:[]`',
|
||||
'',
|
||||
'## S02: Features',
|
||||
'',
|
||||
'Prose content.',
|
||||
].join('\n');
|
||||
const mixedSlices = parseRoadmapSlices(mixed);
|
||||
// The ## Slices section takes priority — prose headers outside it aren't picked up
|
||||
assertEq(mixedSlices.length, 1, 'mixed: only 1 slice from ## Slices section');
|
||||
assertEq(mixedSlices[0].id, 'S01', 'mixed: S01 from checkbox');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// N. Dependency range expansion
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== N. Dependency range expansion ===');
|
||||
|
||||
{
|
||||
assertEq(
|
||||
expandDependencies(['S01-S04']),
|
||||
['S01', 'S02', 'S03', 'S04'],
|
||||
'S01-S04 → 4 individual deps',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
expandDependencies(['S01..S03']),
|
||||
['S01', 'S02', 'S03'],
|
||||
'S01..S03 → 3 individual deps',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
expandDependencies(['S01']),
|
||||
['S01'],
|
||||
'single dep passes through',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
expandDependencies(['S01', 'S03-S05']),
|
||||
['S01', 'S03', 'S04', 'S05'],
|
||||
'mixed single + range',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
expandDependencies(['']),
|
||||
[],
|
||||
'empty string filtered out',
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// O. No-separator colon-less: "S01:Title" (no space after colon)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== O. No space after colon ===');
|
||||
|
||||
{
|
||||
const content = '## S01:Foundation\n\n## S02:Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
// The regex uses [:\s.—–-]* which allows colon with no space
|
||||
assertEq(slices.length, 2, 'no-space-colon: 2 slices');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// P. Three-digit padded IDs
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== P. Three-digit padded IDs ===');
|
||||
|
||||
{
|
||||
const content = '## S001: Foundation\n\n## S002: Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'three-digit: 2 slices');
|
||||
assertEq(slices[0].id, 'S001', 'three-digit: S001');
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* session-lock-regression.test.ts — Regression tests for session lock lifecycle.
|
||||
*
|
||||
* Regression coverage for:
|
||||
* #1257 False-positive "Session lock lost" during auto-mode
|
||||
* #1245 Stranded .gsd.lock/ directory preventing new sessions
|
||||
* #1251 Same root cause as #1245
|
||||
*
|
||||
* Tests the acquire → validate → release lifecycle and edge cases
|
||||
* without requiring concurrent processes.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
acquireSessionLock,
|
||||
validateSessionLock,
|
||||
releaseSessionLock,
|
||||
readSessionLockData,
|
||||
updateSessionLock,
|
||||
isSessionLockHeld,
|
||||
} from '../session-lock.ts';
|
||||
import { gsdRoot } from '../paths.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── 1. Basic acquire/release lifecycle ───────────────────────────────
|
||||
console.log('\n=== 1. acquire → validate → release lifecycle ===');
|
||||
{
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
|
||||
try {
|
||||
const result = acquireSessionLock(base);
|
||||
assertTrue(result.acquired, 'lock acquired successfully');
|
||||
|
||||
const valid = validateSessionLock(base);
|
||||
assertTrue(valid, 'lock validates after acquisition');
|
||||
|
||||
assertTrue(isSessionLockHeld(base), 'isSessionLockHeld returns true');
|
||||
|
||||
releaseSessionLock(base);
|
||||
|
||||
// After release, the lock file should be cleaned up
|
||||
const lockFile = join(gsdRoot(base), 'auto.lock');
|
||||
assertTrue(!existsSync(lockFile), 'lock file removed after release');
|
||||
|
||||
// The .gsd.lock/ directory should be cleaned up
|
||||
const lockDir = gsdRoot(base) + '.lock';
|
||||
assertTrue(!existsSync(lockDir), '.gsd.lock/ directory removed after release (#1245)');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. Double release is safe ────────────────────────────────────────
|
||||
console.log('\n=== 2. double release does not throw ===');
|
||||
{
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
|
||||
try {
|
||||
acquireSessionLock(base);
|
||||
releaseSessionLock(base);
|
||||
// Second release should not throw
|
||||
let threw = false;
|
||||
try {
|
||||
releaseSessionLock(base);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assertTrue(!threw, 'double release does not throw');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3. updateSessionLock preserves lock data ─────────────────────────
|
||||
console.log('\n=== 3. updateSessionLock writes metadata ===');
|
||||
{
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
|
||||
try {
|
||||
acquireSessionLock(base);
|
||||
|
||||
updateSessionLock(base, 'execute-task', 'M001/S01/T01', 5, '/tmp/session.json');
|
||||
|
||||
const data = readSessionLockData(base);
|
||||
assertTrue(data !== null, 'lock data readable after update');
|
||||
if (data) {
|
||||
assertEq(data.pid, process.pid, 'lock data has correct PID');
|
||||
assertEq(data.unitType, 'execute-task', 'lock data has correct unit type');
|
||||
assertEq(data.unitId, 'M001/S01/T01', 'lock data has correct unit ID');
|
||||
assertEq(data.completedUnits, 5, 'lock data has correct completed count');
|
||||
assertEq(data.sessionFile, '/tmp/session.json', 'lock data has session file');
|
||||
}
|
||||
|
||||
releaseSessionLock(base);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. Stale lock from dead PID → re-acquirable (#1245) ─────────────
|
||||
console.log('\n=== 4. stale lock from dead PID → re-acquirable ===');
|
||||
{
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
|
||||
try {
|
||||
// Write a lock file with a definitely-dead PID
|
||||
const lockFile = join(gsdRoot(base), 'auto.lock');
|
||||
const staleLock = {
|
||||
pid: 99999999, // extremely unlikely to be alive
|
||||
startedAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
unitType: 'execute-task',
|
||||
unitId: 'M001/S01/T01',
|
||||
unitStartedAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
completedUnits: 3,
|
||||
};
|
||||
writeFileSync(lockFile, JSON.stringify(staleLock, null, 2));
|
||||
|
||||
// Should be able to acquire despite the stale lock
|
||||
const result = acquireSessionLock(base);
|
||||
assertTrue(result.acquired, '#1245: stale lock from dead PID → re-acquirable');
|
||||
|
||||
releaseSessionLock(base);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 5. readSessionLockData with no lock → null ───────────────────────
|
||||
console.log('\n=== 5. readSessionLockData with no lock → null ===');
|
||||
{
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
|
||||
try {
|
||||
const data = readSessionLockData(base);
|
||||
assertEq(data, null, 'no lock file → null');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. validateSessionLock after own acquisition → true ──────────────
|
||||
console.log('\n=== 6. validateSessionLock after own acquisition → true ===');
|
||||
{
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
|
||||
try {
|
||||
acquireSessionLock(base);
|
||||
|
||||
// Multiple validations should all return true (regression for #1257)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const valid = validateSessionLock(base);
|
||||
assertTrue(valid, `#1257: validation ${i + 1} returns true for own lock`);
|
||||
}
|
||||
|
||||
releaseSessionLock(base);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 7. readSessionLockData with corrupt JSON → null ──────────────────
|
||||
console.log('\n=== 7. corrupt lock file → null ===');
|
||||
{
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
|
||||
try {
|
||||
const lockFile = join(gsdRoot(base), 'auto.lock');
|
||||
writeFileSync(lockFile, 'NOT VALID JSON {{{');
|
||||
|
||||
const data = readSessionLockData(base);
|
||||
assertEq(data, null, 'corrupt JSON → null');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 8. Acquire after release is possible ─────────────────────────────
|
||||
console.log('\n=== 8. acquire after release → re-acquirable ===');
|
||||
{
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
|
||||
try {
|
||||
const r1 = acquireSessionLock(base);
|
||||
assertTrue(r1.acquired, 'first acquisition');
|
||||
releaseSessionLock(base);
|
||||
|
||||
const r2 = acquireSessionLock(base);
|
||||
assertTrue(r2.acquired, 're-acquisition after release');
|
||||
releaseSessionLock(base);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue