Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope. - git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved) - .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local - CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list - README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph - plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path - plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed) Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
106 lines
4.3 KiB
JavaScript
106 lines
4.3 KiB
JavaScript
// lib/validators/progress-validator.mjs
|
|
// Validate progress.json shape + resume-readiness.
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
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'];
|
|
|
|
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}]`));
|
|
}
|
|
}
|
|
|
|
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 && !['completed', 'in_progress', 'failed', 'pending', 'deferred', 'skipped'].includes(s.status)) {
|
|
warnings.push(issue('PROGRESS_STEP_BAD_STATUS', `steps["${k}"].status "${s.status}" unrecognized`));
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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: progress-validator.mjs [--quick] <progress.json>\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);
|
|
}
|