// tests/commands/trekexecute-progress-schema.test.mjs // The progress file /trekexecute's prose tells the executor to write must pass the // command's own validator. Measured 2026-10-05: 3 of 4 real progress files used // step status `passed` (warnings) and 2 of 4 failed outright on the missing // `plan_version`, because the prose and lib/validators/progress-validator.mjs // spoke different vocabularies. // // - SC4: the `### Progress file schema` fence, filled in, validates with 0 errors // and 0 warnings for every documented run status. // - SC5: every status value the prose instructs the executor to WRITE is in the // validator's lists. Excluded on purpose (their own vocabularies): // plan_verification.status, the session-state / NEXT-SESSION-PROMPT status, // the stats and report `result`, and the tolerant session-orders reader list. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as progressValidator from '../../lib/validators/progress-validator.mjs'; const { validateProgress, checkResumeReadiness, PROGRESS_VALID_STATUSES, PROGRESS_STEP_STATUSES } = progressValidator; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md'); const read = () => readFileSync(COMMAND_FILE, 'utf8'); // The one sentence that names the legacy word on purpose (resume tolerance for // progress files written under the old prose). Matched exactly; nothing else may. const LEGACY_SENTENCE = 'A legacy progress file may say `passed` for a finished step; treat it as `completed`.'; /** The JSON fence right after `### Progress file schema`, parsed. */ function schemaObject(text) { const at = text.indexOf('### Progress file schema'); assert.ok(at >= 0, 'trekexecute.md must have "### Progress file schema"'); const m = /```json\n([\s\S]*?)```/.exec(text.slice(at)); assert.ok(m, 'the progress schema section must carry a json fence'); return JSON.parse(m[1]); } const alternatives = (v) => v.replace(/^\{|\}$/g, '').split('|').map((x) => x.trim()); /** Fill `{a | b}` with its first alternative and free placeholders with a sample. */ function fill(value) { if (typeof value !== 'string' || !/^\{.*\}$/.test(value)) return value; if (value.includes('|')) return alternatives(value)[0]; return value === '{ISO-8601}' ? '2026-10-05T12:00:00Z' : '.claude/projects/x/plan.md'; } /** The step-finish status: the "Update progress" line that also records the commit. */ function finishedStepStatus(text) { const m = /Update progress: `steps\.\{N\}\.status = "([^"]+)"`, `steps\.\{N\}\.commit/.exec(text); assert.ok(m, 'the step-finish "Update progress" line must set steps.{N}.status and .commit'); return m[1]; } /** Every status value the prose tells the executor to write, with its line. */ function writtenStatuses(text) { const lines = text.split('\n'); const out = []; const add = (line, value, kind, site) => out.push({ line, value, kind, site }); const lineOf = (index) => text.slice(0, index).split('\n').length; const schema = schemaObject(text); const schemaLine = lineOf(text.indexOf('### Progress file schema')); for (const v of alternatives(schema.status)) add(schemaLine, v, 'run', 'schema'); for (const s of Object.values(schema.steps)) add(schemaLine, s.status, 'step', 'schema'); lines.forEach((l, i) => { const n = i + 1; const fresh = /progress file exists with status (.+?):/.exec(l); if (fresh) for (const m of fresh[1].matchAll(/`([^`]+)`/g)) add(n, m[1], 'run', 'fresh-mode'); for (const m of l.matchAll(/first step (?:with|where) status != `([^`]+)`/g)) add(n, m[1], 'step', 'resume'); for (const m of l.matchAll(/steps with `status=([a-z_-]+)`/g)) add(n, m[1], 'step', 'phase-7.5'); for (const m of l.matchAll(/steps\.\{N\}\.status = "([^"]+)"/g)) add(n, m[1], 'step', 'steps.{N}.status'); if (/update progress/i.test(l)) { for (const m of l.matchAll(/`status: "([^"]+)"`/g)) add(n, m[1], 'run', 'update-progress'); const to = /`status` to ((?:`[a-z_-]+`\/?)+)/.exec(l); if (to) for (const m of to[1].matchAll(/`([^`]+)`/g)) add(n, m[1], 'run', 'update-progress'); } }); // The escalation rule spans a line break: "steps with\n`status: "X"`". for (const m of text.matchAll(/steps with\s+`status: "([^"]+)"`/g)) add(lineOf(m.index), m[1], 'step', 'escalation'); // So does the Step 0 sentinel: "Set step\nstatus = `blocked`". for (const m of text.matchAll(/Set step\s+status = [`"]([^`"]+)[`"]/g)) add(lineOf(m.index), m[1], 'step', 'set-step'); return out; } function offenders(entries) { return entries.filter(({ value, kind }) => !(kind === 'run' ? PROGRESS_VALID_STATUSES : PROGRESS_STEP_STATUSES).includes(value)) .map(({ line, value, kind, site }) => `trekexecute.md:${line} ${site} ${kind} status "${value}"`); } test('SC4: each documented run status validates with 0 errors and 0 warnings', () => { const text = read(); const schema = schemaObject(text); const runStatuses = alternatives(schema.status); for (const s of ['stopped', 'partial']) assert.ok(runStatuses.includes(s), `the schema must document run status ${s}`); const finished = finishedStepStatus(text); const dir = mkdtempSync(join(tmpdir(), 'trekexecute-progress-')); try { for (const status of runStatuses) { const p = Object.fromEntries(Object.entries(schema).map(([k, v]) => [k, fill(v)])); const step = Object.fromEntries(Object.entries(schema.steps['1']).map(([k, v]) => [k, fill(v)])); p.status = status; p.total_steps = 3; p.current_step = 3; p.steps = { 1: { ...step, status: finished }, 2: { ...step, status: finished }, 3: { ...step, status: finished } }; const file = join(dir, `progress-${status}.json`); writeFileSync(file, JSON.stringify(p)); const r = validateProgress(file); assert.deepEqual(r.errors.map((e) => e.code), [], `run status ${status}: ${JSON.stringify(r.errors)}`); assert.deepEqual(r.warnings.map((w) => w.code), [], `run status ${status}: ${JSON.stringify(r.warnings)}`); } } finally { rmSync(dir, { recursive: true, force: true }); } }); // Regression guard (a validator contract; resume itself is prose-driven). test('SC4: every documented run status but completed stays resumable', () => { for (const status of alternatives(schemaObject(read()).status)) { const r = checkResumeReadiness({ status, current_step: 1 }); assert.equal(r.valid, status !== 'completed', `run status ${status}`); } }); test('SC5: every status the prose tells the executor to write is in the validator lists', () => { const text = read(); const entries = writtenStatuses(text); const sites = new Set(entries.map((e) => e.site)); for (const site of ['schema', 'fresh-mode', 'resume', 'phase-7.5', 'set-step', 'steps.{N}.status', 'update-progress', 'escalation']) { assert.ok(sites.has(site), `the ${site} extractor found nothing - teach it the new wording`); } assert.equal(entries.filter((e) => e.site === 'resume').length, 2, 'both resume rules'); // The Step 0 sentinel's "Set step\nstatus = `blocked`" wraps a line; the guard // above was satisfied by another set-step line while this one went unread. const setStep = entries.filter((e) => e.site === 'set-step').map((e) => e.value); assert.ok(setStep.includes('blocked'), `the Step 0 sentinel must be read; set-step saw: ${setStep.join(', ')}`); const updateLines = text.split('\n').filter((l) => /update progress/i.test(l)); assert.ok(updateLines.length >= 5, `expected >= 5 "Update progress" sites, found ${updateLines.length}`); const withValue = new Set(entries.filter((e) => e.site === 'update-progress' || e.site === 'steps.{N}.status').map((e) => e.line)); const lines = text.split('\n'); const silent = lines.map((l, i) => [i + 1, l]).filter(([n, l]) => /update progress/i.test(l) && !withValue.has(n)); assert.deepEqual(silent, [], 'an "Update progress" site whose status the extractor cannot read'); assert.deepEqual(offenders(entries), []); }); test('SC5: the legacy word appears only in the resume-tolerance sentence', () => { const text = read(); assert.equal(text.split(LEGACY_SENTENCE).length - 1, 2, 'one tolerance sentence after each resume rule'); }); // Known-positive control: the extractors DO see today's (pre-fix) wording. test('SC5 control: the extractors report the old vocabulary', () => { const old = [ '### Progress file schema', '```json', '{ "status": "{in-progress | completed | failed | stopped}", "steps": { "1": { "status": "pending" } } }', '```', '- If a progress file exists with status `in-progress` or `failed`: warn that', '- If progress file exists: find the first step with status != `passed`.', '- **resume**: first step where status != `passed`', 'Update progress: `steps.{N}.status = "running"`, `current_step = N`, `updated_at = now`.', ' number of steps with `status=passed` (excludes Step 0 if sandbox_preflight).', ].join('\n'); const bad = offenders(writtenStatuses(old)).join('\n'); for (const word of ['"in-progress"', '"passed"', '"running"']) assert.ok(bad.includes(word), `control must flag ${word}:\n${bad}`); });