// lib/validators/progress-validator.mjs // Validate progress.json shape + resume-readiness. // Forward-compat: unknown keys are tolerated silently, so additive-optional fields land // WITHOUT a schema_version bump. `iterations_remaining` (trekexecute's recovery/retry budget // signal) is shape-checked here only when present (non-negative integer); it is NOT in // PROGRESS_REQUIRED_TOP, so legacy progress.json without it still validates. The field's // liveness (never-decremented) is enforced deterministically by the Phase 7.5 completion-gate // cross-check in trekexecute, not by this per-validation shape check. // The same holds for the wave-1 step-loop fields (per step: start_sha, report_path, // red_proof, test_lock, fix_rounds 0..5; per run: decisions): shape-checked when // present, never required. So are the wave-2 coupling fields (per step: step_check // from K9, regression from K10; per run: plan_approval, arch_rules, scope_sweep, // simplify, review) and the key of plan_verification.failed_criteria: the runner // says `exitCode`, progress says `exit_code`, and only `exit_code` is accepted (C15). import { readFileSync, existsSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { issue, fail } from '../util/result.mjs'; export const PROGRESS_REQUIRED_TOP = ['schema_version', 'plan', 'plan_version', 'mode', 'status', 'total_steps', 'current_step', 'steps']; export const PROGRESS_VALID_STATUSES = ['pending', 'in_progress', 'completed', 'failed', 'partial', 'stopped']; // Step statuses /trekexecute stores. `blocked` is the Step 0 sandbox sentinel; `stopped` above // is the escalation / dirty-tree / entry-condition stop. Both are states other readers already // know (lib/util/session-orders.mjs ENDED_BAD). export const PROGRESS_STEP_STATUSES = ['completed', 'in_progress', 'failed', 'pending', 'deferred', 'skipped', 'blocked']; // Wave 1 (P8/P9) per-step fields, all optional on read so an older progress.json // still resumes; `null` means "not set yet". fix_rounds is the step's place on // the upward ladder: 0 = passed its first control, 1-3 = fix rounds, 4 = sent up // to the plan, 5 = sent up to the operator. There is no sixth round. export const PROGRESS_MAX_FIX_ROUNDS = 5; export const PROGRESS_RED_PROOF_VALUES = ['proven', 'rejected', 'not-run']; export const PROGRESS_TEST_LOCK_VALUES = ['locked', 'released', 'not-locked']; // Wave 2 (order 6) coupling fields. Each value is the exit of a contract CLI said // in words; "not-measured" / "not-run" are never green, and "absent" means the plan // declared nothing (no `Regression:` field), so nothing new happened. export const PROGRESS_STEP_CHECK_VALUES = ['clean', 'findings', 'not-measured']; // K9 exit 0 / 1 / 2 export const PROGRESS_REGRESSION_VALUES = ['passed', 'failed', 'unrunnable', 'not-run', 'absent']; // K10 export const PROGRESS_PLAN_APPROVAL_VALUES = ['approved', 'not-checked']; // K2; not-checked = a session spec export const PROGRESS_ARCH_RULES_VALUES = ['pass', 'violation', 'not-measured']; // K5 exit 0 / 1 / 2 export const PROGRESS_SCOPE_SWEEP_VALUES = ['pass', 'deviation', 'not-measured']; // K6 exit 0 / 1 / 2 export const PROGRESS_SIMPLIFY_VALUES = ['applied', 'clean', 'reverted', 'not-run']; export const PROGRESS_REVIEW_GATE_EXITS = [0, 1, 2]; // K7: ALLOW/WARN, BLOCK, could not decide function checkStepFields(k, s, errors) { const set = (v) => v !== undefined && v !== null; if (s.fix_rounds !== undefined && !(Number.isInteger(s.fix_rounds) && s.fix_rounds >= 0 && s.fix_rounds <= PROGRESS_MAX_FIX_ROUNDS)) { errors.push(issue('PROGRESS_FIX_ROUNDS_INVALID', `steps["${k}"].fix_rounds=${JSON.stringify(s.fix_rounds)} must be an integer 0..${PROGRESS_MAX_FIX_ROUNDS}`)); } if (set(s.start_sha) && !(typeof s.start_sha === 'string' && /^[0-9a-f]{7,64}$/.test(s.start_sha))) { errors.push(issue('PROGRESS_START_SHA_INVALID', `steps["${k}"].start_sha must be a git sha, got ${JSON.stringify(s.start_sha)}`)); } if (set(s.report_path) && !(typeof s.report_path === 'string' && s.report_path.trim() !== '')) { errors.push(issue('PROGRESS_REPORT_PATH_INVALID', `steps["${k}"].report_path must be a non-empty path`)); } if (set(s.red_proof) && !PROGRESS_RED_PROOF_VALUES.includes(s.red_proof)) { errors.push(issue('PROGRESS_RED_PROOF_INVALID', `steps["${k}"].red_proof "${s.red_proof}" not in [${PROGRESS_RED_PROOF_VALUES.join(', ')}]`)); } if (set(s.test_lock) && !PROGRESS_TEST_LOCK_VALUES.includes(s.test_lock)) { errors.push(issue('PROGRESS_TEST_LOCK_INVALID', `steps["${k}"].test_lock "${s.test_lock}" not in [${PROGRESS_TEST_LOCK_VALUES.join(', ')}]`)); } if (set(s.step_check) && !PROGRESS_STEP_CHECK_VALUES.includes(s.step_check)) { errors.push(issue('PROGRESS_STEP_CHECK_INVALID', `steps["${k}"].step_check ${JSON.stringify(s.step_check)} not in [${PROGRESS_STEP_CHECK_VALUES.join(', ')}]`)); } if (set(s.regression) && !PROGRESS_REGRESSION_VALUES.includes(s.regression)) { errors.push(issue('PROGRESS_REGRESSION_INVALID', `steps["${k}"].regression ${JSON.stringify(s.regression)} not in [${PROGRESS_REGRESSION_VALUES.join(', ')}]`)); } } const isStr = (v) => typeof v === 'string' && v.trim() !== ''; // Wave 2 (order 6) run-level fields; all optional, shape-checked when set. function checkCouplingFields(p, errors) { const set = (v) => v !== undefined && v !== null; const oneOf = (field, values, code) => { if (set(p[field]) && !values.includes(p[field])) { errors.push(issue(code, `${field} ${JSON.stringify(p[field])} not in [${values.join(', ')}]`)); } }; oneOf('plan_approval', PROGRESS_PLAN_APPROVAL_VALUES, 'PROGRESS_PLAN_APPROVAL_INVALID'); oneOf('arch_rules', PROGRESS_ARCH_RULES_VALUES, 'PROGRESS_ARCH_RULES_INVALID'); oneOf('simplify', PROGRESS_SIMPLIFY_VALUES, 'PROGRESS_SIMPLIFY_INVALID'); const sw = p.scope_sweep; if (set(sw) && !(typeof sw === 'object' && PROGRESS_SCOPE_SWEEP_VALUES.includes(sw.status) && Array.isArray(sw.outside) && sw.outside.every(isStr))) { errors.push(issue('PROGRESS_SCOPE_SWEEP_INVALID', `scope_sweep must be {status: ${PROGRESS_SCOPE_SWEEP_VALUES.join('|')}, outside: [paths]}`)); } const rv = p.review; if (set(rv) && !(typeof rv === 'object' && Number.isInteger(rv.round) && rv.round >= 1 && PROGRESS_REVIEW_GATE_EXITS.includes(rv.gate_exit))) { errors.push(issue('PROGRESS_REVIEW_INVALID', 'review must be {round: integer >= 1, gate_exit: 0|1|2, ...}')); } // C15: the criteria runner reports `exitCode`; progress.json carries `exit_code` // (an integer, or null when the criterion never ran). The executor translates; // the runner's key here means the translation was skipped. const fc = p.plan_verification?.failed_criteria; if (fc !== undefined && fc !== null) { const ok = Array.isArray(fc) && fc.every((c) => c && typeof c === 'object' && !('exitCode' in c) && 'exit_code' in c && (c.exit_code === null || Number.isInteger(c.exit_code))); if (!ok) { errors.push(issue('PROGRESS_FAILED_CRITERIA_KEY', 'plan_verification.failed_criteria entries carry exit_code (integer or null), never the runner key exitCode')); } } } // The run-level decision log (P4): every decision the executor took on its own, // with what it costs if it is wrong. Optional; when present, every entry carries both. function checkDecisions(decisions, errors) { if (decisions === undefined) return; const ok = Array.isArray(decisions) && decisions.every((d) => d && typeof d === 'object' && typeof d.decision === 'string' && d.decision.trim() !== '' && typeof d.cost_if_wrong === 'string' && d.cost_if_wrong.trim() !== ''); if (!ok) errors.push(issue('PROGRESS_DECISION_INVALID', 'decisions must be an array of {decision, cost_if_wrong} with both non-empty')); } export function validateProgressContent(jsonText, opts = {}) { let parsed; try { parsed = JSON.parse(jsonText); } catch (e) { return fail(issue('PROGRESS_PARSE_ERROR', `Cannot parse JSON: ${e.message}`)); } return validateProgressObject(parsed, opts); } export function validateProgressObject(parsed, opts = {}) { const errors = []; const warnings = []; if (typeof parsed !== 'object' || parsed === null) { return fail(issue('PROGRESS_NOT_OBJECT', 'Progress payload is not an object')); } for (const k of PROGRESS_REQUIRED_TOP) { if (!(k in parsed)) { errors.push(issue('PROGRESS_MISSING_FIELD', `Required field missing: ${k}`)); } } if (parsed.schema_version !== undefined && parsed.schema_version !== '1') { errors.push(issue('PROGRESS_SCHEMA_MISMATCH', `schema_version "${parsed.schema_version}" not supported (expected "1")`)); } if (parsed.status !== undefined && !PROGRESS_VALID_STATUSES.includes(parsed.status)) { errors.push(issue('PROGRESS_BAD_STATUS', `status "${parsed.status}" not in [${PROGRESS_VALID_STATUSES.join(', ')}]`)); } if (typeof parsed.total_steps === 'number' && typeof parsed.current_step === 'number') { if (parsed.current_step < 0 || parsed.current_step > parsed.total_steps) { errors.push(issue('PROGRESS_STEP_RANGE', `current_step=${parsed.current_step} outside [0, ${parsed.total_steps}]`)); } } // Additive-optional: iterations_remaining is trekexecute's recovery/retry budget signal. // When present it must be a non-negative integer; absence is valid (backward-compat). if (parsed.iterations_remaining !== undefined && !(Number.isInteger(parsed.iterations_remaining) && parsed.iterations_remaining >= 0)) { errors.push(issue( 'PROGRESS_ITERATIONS_REMAINING_INVALID', `iterations_remaining=${parsed.iterations_remaining} must be a non-negative integer`, )); } if (parsed.steps && typeof parsed.steps === 'object') { const stepKeys = Object.keys(parsed.steps); if (typeof parsed.total_steps === 'number' && stepKeys.length !== parsed.total_steps) { warnings.push(issue( 'PROGRESS_STEP_COUNT_MISMATCH', `total_steps=${parsed.total_steps} but steps map has ${stepKeys.length} entries`, )); } for (const k of stepKeys) { const s = parsed.steps[k]; if (s === null || typeof s !== 'object') { errors.push(issue('PROGRESS_STEP_SHAPE', `steps["${k}"] is not an object`)); continue; } if (s.status !== undefined && !PROGRESS_STEP_STATUSES.includes(s.status)) { warnings.push(issue('PROGRESS_STEP_BAD_STATUS', `steps["${k}"].status "${s.status}" unrecognized`)); } checkStepFields(k, s, errors); } } checkDecisions(parsed.decisions, errors); checkCouplingFields(parsed, errors); return { valid: errors.length === 0, errors, warnings, parsed }; } export function checkResumeReadiness(progressObj) { const errors = []; if (progressObj.status === 'completed') { return { valid: false, errors: [issue('PROGRESS_ALREADY_DONE', 'Run is already completed; nothing to resume')], warnings: [], parsed: progressObj }; } if (typeof progressObj.current_step !== 'number') { errors.push(issue('PROGRESS_NO_CURRENT', 'No current_step in progress.json')); } return { valid: errors.length === 0, errors, warnings: [], parsed: progressObj }; } export function validateProgress(filePath, opts = {}) { if (!existsSync(filePath)) return fail(issue('PROGRESS_NOT_FOUND', `File not found: ${filePath}`)); let text; try { text = readFileSync(filePath, 'utf-8'); } catch (e) { return fail(issue('PROGRESS_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); } return validateProgressContent(text, opts); } // The real paths on both sides: a `file://` comparison fails when the path holds a // space or runs through a symlink, and the CLI then exits 0 having checked nothing. function isMain() { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } } if (isMain()) { const args = process.argv.slice(2); const filePath = args.find(a => !a.startsWith('--')); if (!filePath) { process.stderr.write('Usage: progress-validator.mjs [--quick] \n'); process.exit(2); } const r = validateProgress(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(`progress-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); }