diff --git a/plugins/ultraplan-local/lib/validators/session-state-validator.mjs b/plugins/ultraplan-local/lib/validators/session-state-validator.mjs new file mode 100644 index 0000000..71fb5f7 --- /dev/null +++ b/plugins/ultraplan-local/lib/validators/session-state-validator.mjs @@ -0,0 +1,117 @@ +// lib/validators/session-state-validator.mjs +// Validate .session-state.local.json — the contract consumed by /ultracontinue. +// Schema v1 documented in docs/HANDOVER-CONTRACTS.md (Handover 7). + +import { readFileSync, existsSync } from 'node:fs'; +import { issue, fail } from '../util/result.mjs'; + +export const SESSION_STATE_REQUIRED_TOP = [ + 'schema_version', + 'project', + 'next_session_brief_path', + 'next_session_label', + 'status', + 'updated_at', +]; + +// All five statuses parse as valid; `completed` emits a warning that the +// session is not resumable. Unknown statuses fail. +export const SESSION_STATE_VALID_STATUSES = ['in_progress', 'partial', 'failed', 'stopped', 'completed']; + +// Statuses that /ultracontinue can resume from. `completed` is intentionally +// excluded — running ultracontinue on a completed project should signal "no +// further sessions to resume", not load stale context. +export const SESSION_STATE_RESUMABLE_STATUSES = ['in_progress', 'partial', 'failed', 'stopped']; + +export function validateSessionStateContent(jsonText, opts = {}) { + let parsed; + try { parsed = JSON.parse(jsonText); } + catch (e) { + return fail(issue('SESSION_STATE_PARSE_ERROR', `Cannot parse JSON: ${e.message}`)); + } + return validateSessionStateObject(parsed, opts); +} + +export function validateSessionStateObject(parsed, opts = {}) { + const errors = []; + const warnings = []; + + if (typeof parsed !== 'object' || parsed === null) { + return fail(issue('SESSION_STATE_NOT_OBJECT', 'Session-state payload is not an object')); + } + + for (const k of SESSION_STATE_REQUIRED_TOP) { + if (!(k in parsed)) { + errors.push(issue('SESSION_STATE_MISSING_FIELD', `Required field missing: ${k}`)); + } + } + + if (parsed.schema_version !== undefined && parsed.schema_version !== 1) { + errors.push(issue( + 'SESSION_STATE_SCHEMA_MISMATCH', + `schema_version ${JSON.stringify(parsed.schema_version)} not supported (expected 1)`, + )); + } + + if (parsed.status !== undefined) { + if (!SESSION_STATE_VALID_STATUSES.includes(parsed.status)) { + errors.push(issue( + 'SESSION_STATE_INVALID_STATUS', + `status "${parsed.status}" not in [${SESSION_STATE_VALID_STATUSES.join(', ')}]`, + )); + } else if (parsed.status === 'completed') { + warnings.push(issue( + 'SESSION_STATE_NOT_RESUMABLE', + 'status "completed" — project is done; no further sessions to resume', + )); + } + } + + if (parsed.next_session_brief_path !== undefined) { + if (typeof parsed.next_session_brief_path !== 'string' || parsed.next_session_brief_path.length === 0) { + errors.push(issue('SESSION_STATE_INVALID_PATH', 'next_session_brief_path must be a non-empty string')); + } + } + + if (parsed.updated_at !== undefined) { + if (typeof parsed.updated_at !== 'string' || Number.isNaN(Date.parse(parsed.updated_at))) { + errors.push(issue('SESSION_STATE_INVALID_TIMESTAMP', `updated_at "${parsed.updated_at}" is not a valid ISO-8601 timestamp`)); + } + } + + // Forward-compat: unknown top-level keys are tolerated silently. + // This protects future graceful-handoff v2.2 dual-writes that emit + // additional fields (branch, git_status, committed_by, ...). + + return { valid: errors.length === 0, errors, warnings, parsed }; +} + +export function validateSessionState(filePath, opts = {}) { + if (!existsSync(filePath)) { + return fail(issue('SESSION_STATE_NOT_FOUND', `File not found: ${filePath}`)); + } + let text; + try { text = readFileSync(filePath, 'utf-8'); } + catch (e) { + return fail(issue('SESSION_STATE_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); + } + return validateSessionStateContent(text, opts); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + const filePath = args.find(a => !a.startsWith('--')); + if (!filePath) { + process.stderr.write('Usage: session-state-validator.mjs [--json] <.session-state.local.json>\n'); + process.exit(2); + } + const r = validateSessionState(filePath); + if (args.includes('--json')) { + process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n'); + } else { + process.stdout.write(`session-state-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`); + for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`); + for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`); + } + process.exit(r.valid ? 0 : 1); +} diff --git a/plugins/ultraplan-local/tests/validators/session-state-validator.test.mjs b/plugins/ultraplan-local/tests/validators/session-state-validator.test.mjs new file mode 100644 index 0000000..72a67f0 --- /dev/null +++ b/plugins/ultraplan-local/tests/validators/session-state-validator.test.mjs @@ -0,0 +1,145 @@ +// tests/validators/session-state-validator.test.mjs +// Unit + integration tests for lib/validators/session-state-validator.mjs. +// Schema v1 contract — see docs/HANDOVER-CONTRACTS.md (Handover 7). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + validateSessionStateObject, + validateSessionStateContent, + validateSessionState, +} from '../../lib/validators/session-state-validator.mjs'; + +function goodState() { + return { + schema_version: 1, + project: '.claude/projects/2026-05-01-example', + next_session_brief_path: '.claude/projects/2026-05-01-example/brief.md', + next_session_label: 'Session 2: Implement validator + tests', + status: 'in_progress', + updated_at: '2026-05-01T18:00:00.000Z', + }; +} + +test('validateSessionStateObject — happy path returns valid', () => { + const r = validateSessionStateObject(goodState()); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + assert.deepEqual(r.warnings, []); +}); + +test('validateSessionStateObject — missing project field', () => { + const s = goodState(); + delete s.project; + const r = validateSessionStateObject(s); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /project/.test(e.message))); +}); + +test('validateSessionStateObject — missing status field', () => { + const s = goodState(); + delete s.status; + const r = validateSessionStateObject(s); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /status/.test(e.message))); +}); + +test('validateSessionStateObject — missing next_session_brief_path', () => { + const s = goodState(); + delete s.next_session_brief_path; + const r = validateSessionStateObject(s); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /next_session_brief_path/.test(e.message))); +}); + +test('validateSessionStateObject — missing next_session_label', () => { + const s = goodState(); + delete s.next_session_label; + const r = validateSessionStateObject(s); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /next_session_label/.test(e.message))); +}); + +test('validateSessionStateObject — missing updated_at', () => { + const s = goodState(); + delete s.updated_at; + const r = validateSessionStateObject(s); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /updated_at/.test(e.message))); +}); + +test('validateSessionStateObject — invalid status value rejected', () => { + const s = goodState(); + s.status = 'maybe'; + const r = validateSessionStateObject(s); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_INVALID_STATUS')); +}); + +test('validateSessionStateObject — status completed valid but warns NOT_RESUMABLE', () => { + const s = goodState(); + s.status = 'completed'; + const r = validateSessionStateObject(s); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + assert.ok(r.warnings.find(w => w.code === 'SESSION_STATE_NOT_RESUMABLE')); +}); + +test('validateSessionStateObject — schema_version mismatch fails', () => { + const s = goodState(); + s.schema_version = 2; + const r = validateSessionStateObject(s); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_SCHEMA_MISMATCH')); +}); + +test('validateSessionStateObject — invalid timestamp rejected', () => { + const s = goodState(); + s.updated_at = 'not-a-date'; + const r = validateSessionStateObject(s); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_INVALID_TIMESTAMP')); +}); + +test('validateSessionStateContent — malformed JSON returns SESSION_STATE_PARSE_ERROR', () => { + const r = validateSessionStateContent('{ broken'); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_PARSE_ERROR')); +}); + +test('validateSessionState — missing file returns SESSION_STATE_NOT_FOUND', () => { + const r = validateSessionState('/tmp/nonexistent-ultracontinue-test-9b2f4e.json'); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_NOT_FOUND')); +}); + +test('validateSessionState — fixture file loads and parses correctly (SC-1)', () => { + const r = validateSessionState('tests/fixtures/session-state/valid-in-progress.json'); + assert.equal(r.valid, true); + assert.equal(r.parsed.status, 'in_progress'); + assert.equal(typeof r.parsed.project, 'string'); + assert.equal(typeof r.parsed.next_session_brief_path, 'string'); + assert.equal(typeof r.parsed.next_session_label, 'string'); +}); + +test('validateSessionState — malformed fixture returns SESSION_STATE_PARSE_ERROR (SC-3)', () => { + const r = validateSessionState('tests/fixtures/session-state/malformed.json'); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_PARSE_ERROR')); +}); + +test('validateSessionStateObject — forward-compat: unknown keys ignored silently', () => { + // Simulates graceful-handoff v2.2 dual-write with extra fields. + const s = { + ...goodState(), + branch: 'main', + git_status: { dirty: false, ahead: 0, detached: false }, + committed_by: 'graceful-handoff', + last_commits: [{ sha: 'abc1234', msg: 'feat: foo' }], + next_steps: ['cd repo', 'git status'], + }; + const r = validateSessionStateObject(s); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + assert.deepEqual(r.warnings, []); +});