fix: the fix round before 6.3.0 (order 10)

1. A step of only tests can never be proven red. One reading,
   stepTestsOnly (lib/parsers/plan-schema.mjs, test paths from the new
   lib/util/test-path.mjs): the plan validator warns PLAN_STEP_TESTS_ONLY,
   red-proof --plan/--step adds tests_only to a not-red-at-base, and
   /trekexecute and the step helper send such a step straight up to the
   plan instead of into fix rounds that cannot succeed. /trekplan, the plan
   template and plan-critic (dimension 8, still 12) say the test belongs in
   the step that builds the behaviour.
2. Timing tests under load: the hook helper's kill limit is 30 s and per
   call overridable; SC #14 passes on the fastest of up to three runs
   against the same 800 ms limit (tests/helpers/timing.mjs).
3. The example plan's On failure lines open with a K11 word.
4. /trekreview saves the reviewer's whole answer for the findings check and
   names FINDING_PROSE_LINE_OUT_OF_RANGE (error: re-ask; warning: report).
5. The criteria runner's commands run without NODE_TEST_CONTEXT
   (commandEnv, moved to lib/util/command-env.mjs).
6. The resolver returns effort only when the brief's phase signal sets one;
   the four commands said it always did.

Also: the K2 hook tests used fixed session ids in a per-user temp dir, so
two suites running at once took each other's approval records; the ids are
now unique per run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-09 22:16:12 +02:00
commit 33b58f460a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
25 changed files with 680 additions and 74 deletions

View file

@ -22,7 +22,7 @@
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1130-1145 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:782-808 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1115-1135 | profile, phase_models, parallel_agents, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2855-2877 | profile, phase_models, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2856-2878 | profile, phase_models, profile_source | none |
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:628-632 | profile, phase_models, profile_source | none |

View file

@ -1,11 +1,20 @@
// hook-helper.mjs — Shared test helper for hook scripts.
// Spawns a hook as a child process and feeds it JSON via stdin.
//
// Source: ../../../llm-security/tests/hooks/hook-helper.mjs (verbatim copy)
// Source: ../../../llm-security/tests/hooks/hook-helper.mjs (copied; since order 10
// the kill limit is HOOK_TIMEOUT_MS and a call can set its own)
// Provenance: borrowed within the same marketplace (same author, MIT).
import { execFile } from 'node:child_process';
// How long a hook may run before the helper kills it. The tests check a
// hook's answer, not its speed (SC #14 times itself, tests/helpers/timing.mjs),
// so the limit only has to catch a hook that hangs. 5 s killed P7
// (post-edit-check) at load 95-177 in four wave sessions while it was 8/8
// green alone (order 10, 2026-10-09). hooks/hooks.json sets no timeout for
// these hooks, so 5 s was stricter than anything the hook meets in use.
export const HOOK_TIMEOUT_MS = 30_000;
/**
* Run a hook script by spawning `node <scriptPath>` and piping `input` to stdin.
*
@ -23,15 +32,16 @@ export function runHook(scriptPath, input) {
* @param {string} scriptPath - Absolute path to the hook .mjs file
* @param {object|string} input - JSON payload (object will be stringified)
* @param {Record<string, string>} envOverrides - Extra env vars to set
* @param {{ timeoutMs?: number }} [opts] - kill limit for this call (default HOOK_TIMEOUT_MS)
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
*/
export function runHookWithEnv(scriptPath, input, envOverrides) {
export function runHookWithEnv(scriptPath, input, envOverrides, opts = {}) {
return new Promise((resolve) => {
const env = { ...process.env, ...envOverrides };
const child = execFile(
'node',
[scriptPath],
{ timeout: 5000, env },
{ timeout: opts.timeoutMs ?? HOOK_TIMEOUT_MS, env },
(err, stdout, stderr) => {
resolve({
code: child.exitCode ?? (err && err.code === 'ERR_CHILD_PROCESS_STDIO_FINAL' ? 0 : 1),

25
tests/helpers/timing.mjs Normal file
View file

@ -0,0 +1,25 @@
// tests/helpers/timing.mjs
// A timing assertion that tolerates a busy machine without loosening its limit.
//
// Load only ever ADDS time to a run, so the fastest of a few runs is the best
// reading of what the code itself costs. `fastestWithin` measures up to
// `tries` runs and passes as soon as one is under the limit. A real slowness
// is slow every time and still fails; one run slowed by four other test suites
// does not (measured S123 and in wave 2, 2026-10-08: SC #14 red under load
// 95-177, green alone).
/**
* @param {number} limitMs - the limit; a run must be strictly under it
* @param {() => Promise<number>} measure - runs once, resolves to its elapsed ms
* @param {number} [tries=3]
* @returns {Promise<{ok: boolean, fastest: number, runs: number[]}>}
*/
export async function fastestWithin(limitMs, measure, tries = 3) {
const runs = [];
for (let i = 0; i < tries; i++) {
runs.push(await measure());
if (runs[runs.length - 1] < limitMs) break;
}
const fastest = Math.min(...runs);
return { ok: fastest < limitMs, fastest, runs };
}

View file

@ -27,12 +27,16 @@ gitIn(REPO, 'checkout', '-q', '-b', 'feat');
const sha = (s) => createHash('sha256').update(s).digest('hex');
let callNo = 0;
// Approval records live in one temp dir per user (hooks/lib/approval-record.mjs),
// keyed by session_id + tool_use_id, so two suites running at once (two
// worktrees) took each other's records with fixed ids. Unique per run.
const RUN = `${process.pid}-${Date.now()}`;
// A Bash call goes through the same two hooks as in a session: the guard asks
// (and records what it asked), the call runs, then the approval is logged.
async function post(root, input, env = {}) {
const logFile = join(tmp(), 'stub.jsonl');
const ids = { session_id: 'sess-log', tool_use_id: `toolu_log_${(callNo += 1)}` };
const ids = { session_id: `sess-log-${RUN}`, tool_use_id: `toolu_log_${(callNo += 1)}` };
if (input.tool_name === 'Bash') {
await runHookWithEnv(join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs'),
{ hook_event_name: 'PreToolUse', cwd: REPO, ...ids, tool_name: 'Bash', tool_input: input.tool_input }, env);

View file

@ -16,6 +16,9 @@ const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
const LOCK = join(ROOT, 'lib', 'util', 'test-lock.mjs');
const STEP = join(ROOT, 'lib', 'verification', 'step-check.mjs');
// Approval records share one temp dir per user, keyed by session_id +
// tool_use_id: fixed ids let two suites running at once take each other's.
const RUN = `${process.pid}-${Date.now()}`;
const { classifyGitApproval } = await import(join(ROOT, 'hooks', 'lib', 'git-approval.mjs'));
const FEAT = makeRepo('main');
@ -81,7 +84,7 @@ test('review 8: the log records what the person was asked, not a re-reading afte
const repo = makeRepo('main');
gitIn(repo, 'checkout', '-q', '-b', 'feat');
const cmd = 'git checkout main && git merge feat';
const base = { session_id: 's-8', tool_use_id: 'toolu_8', cwd: repo, tool_name: 'Bash', tool_input: { command: cmd } };
const base = { session_id: `s-8-${RUN}`, tool_use_id: 'toolu_8', cwd: repo, tool_name: 'Bash', tool_input: { command: cmd } };
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
assert.match(pre.stdout, /merge into main/);
gitIn(repo, 'checkout', '-q', 'main'); // the command ran
@ -97,7 +100,7 @@ test('review 8: a command nobody was asked about is not logged, even if it reads
const repo = makeRepo('main');
const log = join(tmp(), 'stub.jsonl');
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), {
hook_event_name: 'PostToolUse', session_id: 's-9', tool_use_id: 'toolu_never_asked', cwd: repo,
hook_event_name: 'PostToolUse', session_id: `s-9-${RUN}`, tool_use_id: 'toolu_never_asked', cwd: repo,
tool_name: 'Bash', tool_input: { command: 'git push origin main' },
}, { STUB_LOG: log });
assert.equal(readStubLog(log).length, 0);
@ -110,7 +113,7 @@ test('review 8: a command nobody was asked about is not logged, even if it reads
test('uncertain (no-prompt mode): the push to main is refused, and no approval is logged', async () => {
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
const repo = makeRepo('main');
const base = { session_id: 's-b', tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: 'bypassPermissions' };
const base = { session_id: `s-b-${RUN}`, tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: 'bypassPermissions' };
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
assert.equal(pre.code, 2, `refused, not asked: ${JSON.stringify(pre)}`);
const log = join(tmp(), 'stub.jsonl');
@ -297,7 +300,7 @@ test('review 2nd round (N4): when the record cannot be written, the prompt says
const fileAsDir = join(tmp(), 'not-a-dir');
writeFileSync(fileAsDir, 'x');
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), {
hook_event_name: 'PreToolUse', session_id: 's-n4', tool_use_id: 'toolu_n4', cwd: makeRepo('main'),
hook_event_name: 'PreToolUse', session_id: `s-n4-${RUN}`, tool_use_id: 'toolu_n4', cwd: makeRepo('main'),
tool_name: 'Bash', tool_input: { command: 'git push origin main' },
}, { TMPDIR: fileAsDir });
const reason = parseJsonOut(r.stdout).hookSpecificOutput.permissionDecisionReason;

View file

@ -9,6 +9,7 @@ import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
import { fastestWithin } from '../helpers/timing.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOOK_PATH = join(__dirname, '..', '..', 'hooks', 'scripts', 'otel-export.mjs');
@ -104,16 +105,21 @@ test('SC #14: VOYAGE_EXPORT_MODE=otlp + invalid endpoint → stderr [voyage] war
test('SC #14: tail-latency for textfile mode < 200ms (NFR)', async () => {
const dataDir = setupDataDir();
try {
const start = performance.now();
const r = await runHookWithEnv(HOOK_PATH, '{}', {
VOYAGE_EXPORT_MODE: 'textfile',
CLAUDE_PLUGIN_DATA: dataDir,
// The fastest of up to three cold runs against the same 800 ms limit
// (tests/helpers/timing.mjs): load only adds time, so a real slowness
// still fails every run, and one run slowed by a busy machine does not.
const t = await fastestWithin(800, async () => {
const start = performance.now();
const r = await runHookWithEnv(HOOK_PATH, '{}', {
VOYAGE_EXPORT_MODE: 'textfile',
CLAUDE_PLUGIN_DATA: dataDir,
});
assert.equal(r.code, 0);
return performance.now() - start;
});
const elapsed = performance.now() - start;
assert.equal(r.code, 0);
// 200ms NFR with extra headroom for cold-start node spawn (~100ms typical)
assert.ok(elapsed < 800,
`textfile export tail-latency too slow: ${elapsed.toFixed(0)}ms (NFR <200ms in-process; <800ms allowed for cold spawn)`);
assert.ok(t.ok,
`textfile export tail-latency too slow: fastest ${t.fastest.toFixed(0)}ms of ${t.runs.map((x) => x.toFixed(0)).join('/')}ms (NFR <200ms in-process; <800ms allowed for cold spawn)`);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}

View file

@ -0,0 +1,131 @@
// tests/lib/retterunde-prose-pins.test.mjs
// Prose pins for the fix round before 6.3.0 (order 10) whose fix is text a
// session reads. Counted as doc pins by lib/util/test-census.mjs (the file name).
// 1b A step of only tests can never be proven red: the plan text, the
// template and plan-critic say the test belongs in the step that builds
// the behaviour (voyage-lekerepo trial 3, 2026-10-09, finding 1).
// 1c The executor and the step helper pass `--plan`/`--step` to red-proof
// and send a `tests_only` `not-red-at-base` up to the plan, not into fix
// rounds that cannot succeed.
// 4 /trekreview names FINDING_PROSE_LINE_OUT_OF_RANGE and hands the check
// the reviewer's whole answer, not only its JSON block.
// 6 The resolver returns `effort` only when the brief's phase signal sets
// one; no command says it always does.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { findSection } from '../../lib/parsers/plan-schema.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
/** The text of a heading's section (level from its #-count), up to the next one outside a fence. */
function section(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
return s.text;
}
const RULE = /test\s+for\s+a\s+behaviou?r\s+belongs\s+in\s+the\s+step\s+that\s+builds\s+it/i;
// --- 1b --------------------------------------------------------------------------
test('1b: /trekplan says the test belongs in the step that builds the behaviour, and names the warning', () => {
const plan = read('commands/trekplan.md');
assert.match(plan, RULE);
// At the vertical-slice rule and at the step field list.
assert.match(plan, /Steps are vertical slices[\s\S]{0,900}only tests[\s\S]{0,300}never be proven red/i);
assert.match(plan, /\*\*Test first:\*\* — [^\n]*\n[^\n]*step that builds/i);
assert.match(plan, /PLAN_STEP_TESTS_ONLY/);
});
test('1b: the plan template carries the same rule', () => {
const t = read('templates/plan-template.md');
assert.match(t, RULE);
assert.match(t, /only tests[\s\S]{0,200}never be proven red/i);
});
test('1b: plan-critic has it as a finding under Verification gaps, and still 12 dimensions', () => {
const critic = read('agents/plan-critic.md');
const d8 = section(critic, '### 8. Verification gaps');
assert.match(d8, /PLAN_STEP_TESTS_ONLY/);
assert.match(d8, /only tests[\s\S]{0,400}\*\*major\*\*/i);
assert.equal((critic.match(/^### \d+\. /gm) ?? []).length, 12);
});
// --- 1c --------------------------------------------------------------------------
test('1c: the executor runs red-proof with --plan/--step and sends a tests_only not-red-at-base up to the plan', () => {
const ex = read('commands/trekexecute.md');
assert.match(ex, /red-proof\.mjs --base \{start_sha\} --cmd "\{Verify command\}" --tests [^\n]*--plan "\{plan_path\}" --step \{N\}[^\n]*--json/);
assert.match(ex, /`not-red-at-base`, `tests_only: true`[^\n]*straight up to the plan \(Sub-step F, `fix_rounds = 4`, stop class 4\)/);
// The helper's gate, as the executor describes it, carries the same flags.
assert.match(ex, /red-proof\.mjs --red-only --base \{start_sha\} [^\n]*--plan "\{plan_path\}" --step \{N\}/);
// The other not-red-at-base keeps its fix round.
assert.match(ex, /`not-red-at-base`, otherwise[^\n]*finding for Sub-step F/);
});
test('1c: the step helper passes --plan/--step to its red gate and does not rewrite a tests_only test', () => {
const h = read('agents/step-implementer.md');
assert.match(h, /red-proof\.mjs --red-only [^\n]*--plan "\{plan\}" --step \{step\}/);
assert.match(h, /`tests_only: true`[\s\S]{0,300}do not rewrite/i);
});
// --- 4 ---------------------------------------------------------------------------
test('4: /trekreview names FINDING_PROSE_LINE_OUT_OF_RANGE: re-ask on the error, report the warning', () => {
const r = read('commands/trekreview.md');
assert.match(r, /FINDING_PROSE_LINE_OUT_OF_RANGE/);
assert.match(r, /FINDING_PROSE_LINE_OUT_OF_RANGE[\s\S]{0,600}re-ask/i);
assert.match(r, /FINDING_PROSE_LINE_OUT_OF_RANGE[\s\S]{0,900}warning[\s\S]{0,300}report/i);
});
test('4: /trekreview saves the reviewer\'s whole answer for the check, not only the JSON block', () => {
const r = read('commands/trekreview.md');
assert.match(r, /\{project_dir\}\/\.review-out-\{reviewer\}\.md/);
assert.match(r, /whole answer[\s\S]{0,300}not only the\s+(JSON\s+)?block/i);
assert.match(r, /findings-schema\.mjs --json --repo-root [^\n]*"\{project_dir\}\/\.review-out-\{reviewer\}\.md"/);
assert.doesNotMatch(r, /Collect each reviewer's trailing JSON block/);
});
test('4: what the check is given decides: the whole answer warns on a prose citation, the block alone cannot', () => {
const dir = mkdtempSync(join(tmpdir(), 'retterunde-review-'));
try {
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.mjs'), 'export const a = 1;\n');
const block = '```json\n{"reviewer":"code-correctness-reviewer","findings":[]}\n```\n';
writeFileSync(join(dir, 'whole.md'), `The bug is at \`src/a.mjs:40\`.\n\n${block}`);
writeFileSync(join(dir, 'block.md'), block);
const cli = (f) => spawnSync(process.execPath,
[join(ROOT, 'lib', 'review', 'findings-schema.mjs'), '--json', '--repo-root', dir, join(dir, f)], { encoding: 'utf8' });
const whole = JSON.parse(cli('whole.md').stdout);
assert.deepEqual(whole.warnings.map((w) => w.code), ['FINDING_PROSE_LINE_OUT_OF_RANGE']);
assert.deepEqual(JSON.parse(cli('block.md').stdout).warnings, []);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// --- 6 ---------------------------------------------------------------------------
const RESOLVER_COMMANDS = ['trekplan', 'trekexecute', 'trekresearch', 'trekreview'];
test('6: no command says the resolver always returns {effort, model, source}', () => {
for (const f of readdirSync(join(ROOT, 'commands')).filter((x) => x.endsWith('.md'))) {
const t = read(`commands/${f}`);
assert.doesNotMatch(t, /\{effort, model, source\}/, f);
}
});
test('6: each resolver-using command says `effort` comes only from the brief signal, else standard', () => {
for (const c of RESOLVER_COMMANDS) {
const t = read(`commands/${c}.md`);
const hits = t.match(/\{model, source\}[^\n]*\n?[^\n]*effort[^\n]*only when the brief's phase signal sets one/g) ?? [];
assert.ok(hits.length >= 1, `${c}: no statement that effort is only present with a brief signal`);
assert.match(t, /without it[^\n]*\n?[^\n]*`?standard`?/i, c);
}
});

View file

@ -0,0 +1,198 @@
// tests/lib/retterunde.test.mjs
// The fix round before 6.3.0 (order 10), the points that are behaviour, not
// prose. Each test was red before its fix.
// 1 A step whose `Files:` are all tests can never be proven red: the
// behaviour it tests is already at its start point (voyage-lekerepo
// trial 3, 2026-10-09, finding 1). (a) the validator warns
// PLAN_STEP_TESTS_ONLY; (c) red-proof says `tests_only` on its
// `not-red-at-base`, so the executor sends the step up to the plan instead
// of into fix rounds that cannot succeed. One reading: stepTestsOnly.
// 3 The example plan's `On failure:` lines open with a K11 word.
// 5 The criteria runner's commands never see NODE_TEST_CONTEXT: a nested
// `node --test` would report to the parent and exit 0 on a red test.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validatePlan, validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import { stepTestsOnly } from '../../lib/parsers/plan-schema.mjs';
import { proveRed } from '../../lib/verification/red-proof.mjs';
import { runCriteria } from '../../lib/verification/criteria-runner.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const codes = (list) => list.map((e) => e.code);
// --- 1 (a): the validator -----------------------------------------------------
// Step 2 changes only tests for behaviour step 1 delivered, as in the trial.
const TESTS_ONLY_PLAN = buildPlan({
steps: [{}, { file: 'tests/e2e.sh', test: 'tests/e2e-guard.test.mjs', endToEnd: 'bash tests/e2e.sh' }],
});
test('1a: a step whose Files are all tests gives PLAN_STEP_TESTS_ONLY, a warning on that step', () => {
const r = validatePlanContent(TESTS_ONLY_PLAN);
const w = r.warnings.filter((x) => x.code === 'PLAN_STEP_TESTS_ONLY');
assert.equal(w.length, 1, codes(r.warnings).join(','));
assert.match(w[0].message, /Step 2/);
assert.ok(!codes(r.errors).includes('PLAN_STEP_TESTS_ONLY'), 'a warning, never an error');
assert.equal(r.valid, true, codes(r.errors).join(','));
});
test('1a: a step with code and its test gives no PLAN_STEP_TESTS_ONLY (as before)', () => {
const r = validatePlanContent(buildPlan());
assert.ok(!codes(r.warnings).includes('PLAN_STEP_TESTS_ONLY'), codes(r.warnings).join(','));
});
test('1a: the reading - Files all tests (by path or as the Test first file) or not; no Files = no warning', () => {
const body = (files, tf = 'tests/x.test.mjs') => [
'### Step 1: x', '',
...(files === null ? [] : [`- **Files:** ${files}`]),
'- **Test first:**', ` - File: \`${tf}\` (new)`, ' - Verifies: x',
].join('\n');
assert.equal(stepTestsOnly(body('`tests/e2e.sh`, `tests/e2e-guard.sh`')).testsOnly, true);
assert.equal(stepTestsOnly(body('`src/a.test.ts` (new file), `lib/b.spec.js`, `pkg/c_test.go`')).testsOnly, true);
assert.equal(stepTestsOnly(body('`__tests__/a.js`, `test/b.js`')).testsOnly, true);
// The Test first file counts as a test wherever it lives.
assert.equal(stepTestsOnly(body('`scripts/check.sh`', 'scripts/check.sh')).testsOnly, true);
assert.equal(stepTestsOnly(body('`src/cli.mjs`, `tests/cli.test.mjs`')).testsOnly, false);
assert.equal(stepTestsOnly(body('`src/cli.mjs`')).testsOnly, false);
assert.equal(stepTestsOnly(body(null)).testsOnly, false);
assert.deepEqual(stepTestsOnly(body('`tests/e2e.sh`, `tests/e2e-guard.sh`')).files, ['tests/e2e.sh', 'tests/e2e-guard.sh']);
});
test('1a: a plain path beside a backticked one is still read - a step that also changes code is not tests-only', () => {
const body = (files) => ['### Step 1: x', '', `- **Files:** ${files}`].join('\n');
const mixed = stepTestsOnly(body('src/cli.mjs (modify), `tests/x.test.mjs` (new)'));
assert.deepEqual(mixed.files, ['src/cli.mjs', 'tests/x.test.mjs']);
assert.equal(mixed.testsOnly, false);
const list = stepTestsOnly(['### Step 1: x', '', '- **Files:**', ' - `tests/x.test.mjs` (new)', ' - src/cli.mjs'].join('\n'));
assert.equal(list.testsOnly, false, JSON.stringify(list.files));
});
test('1a: a document under spec/ or specs/ is not a test for this reading', () => {
const body = (files) => ['### Step 1: x', '', `- **Files:** ${files}`].join('\n');
assert.equal(stepTestsOnly(body('`docs/specs/api.md`, `specs/001-auth/plan.md`')).testsOnly, false);
assert.equal(stepTestsOnly(body('`spec/cli_spec.rb`')).testsOnly, true);
});
// --- 1 (c): red-proof says tests_only on not-red-at-base ----------------------
function sh(cwd, cmd, args) {
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
assert.equal(r.status, 0, `${cmd} ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
const SUM_TEST = [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
"import { sum } from '../lib/sum.mjs';",
"test('sum adds', () => { assert.equal(sum(1, 2), 3); });",
'',
].join('\n');
/** The behaviour is already at the start point; the step adds only its test. */
function greenAtBase() {
const dir = mkdtempSync(join(tmpdir(), 'retterunde-red-'));
sh(dir, 'git', ['init', '-q']);
sh(dir, 'git', ['config', 'user.email', 't@example.invalid']);
sh(dir, 'git', ['config', 'user.name', 'test']);
mkdirSync(join(dir, 'lib'));
writeFileSync(join(dir, 'lib', 'sum.mjs'), 'export function sum(a, b) { return a + b; }\n');
sh(dir, 'git', ['add', 'lib/sum.mjs']);
sh(dir, 'git', ['commit', '-q', '-m', 'base']);
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'sum.test.mjs'), SUM_TEST);
return { dir, base: sh(dir, 'git', ['rev-parse', 'HEAD']) };
}
function planWithStep2Files(dir, files) {
const p = join(dir, 'plan.local.md');
writeFileSync(p, buildPlan({ steps: [{}, { file: files, test: 'tests/sum.test.mjs', endToEnd: 'bash tests/e2e.sh' }] }));
return p;
}
const CMD = 'node --test tests/sum.test.mjs';
test('1c: not-red-at-base on a step whose Files are all tests says tests_only: true (red-only and full)', () => {
const { dir, base } = greenAtBase();
try {
const plan = planWithStep2Files(dir, 'tests/sum.test.mjs');
for (const redOnly of [true, false]) {
const r = proveRed({ cwd: dir, base, cmd: CMD, tests: ['tests/sum.test.mjs'], redOnly, plan, step: 2 });
assert.equal(r.reason, 'not-red-at-base', JSON.stringify(r));
assert.equal(r.tests_only, true, JSON.stringify(r));
}
const cli = spawnSync(process.execPath, [join(ROOT, 'lib', 'verification', 'red-proof.mjs'),
'--cwd', dir, '--base', base, '--cmd', CMD, '--tests', 'tests/sum.test.mjs', '--plan', plan, '--step', '2', '--json'],
{ encoding: 'utf8' });
assert.equal(cli.status, 1, cli.stdout + cli.stderr);
assert.equal(JSON.parse(cli.stdout).tests_only, true);
// Text mode names the field too (the step helper reads it there).
const text = spawnSync(process.execPath, [join(ROOT, 'lib', 'verification', 'red-proof.mjs'),
'--red-only', '--cwd', dir, '--base', base, '--cmd', CMD, '--tests', 'tests/sum.test.mjs', '--plan', plan, '--step', '2'],
{ encoding: 'utf8' });
assert.match(text.stdout, /tests_only: true/);
// --step without --plan is a usage error, never silently dropped.
const lone = spawnSync(process.execPath, [join(ROOT, 'lib', 'verification', 'red-proof.mjs'),
'--cwd', dir, '--base', base, '--cmd', CMD, '--tests', 'tests/sum.test.mjs', '--step', '2'], { encoding: 'utf8' });
assert.equal(lone.status, 2, lone.stderr);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('1c: not-red-at-base on a step that also changes code says tests_only: false (fix rounds, as before)', () => {
const { dir, base } = greenAtBase();
try {
const plan = planWithStep2Files(dir, 'lib/sum.mjs');
const r = proveRed({ cwd: dir, base, cmd: CMD, tests: ['tests/sum.test.mjs'], plan, step: 2 });
assert.equal(r.reason, 'not-red-at-base');
assert.equal(r.tests_only, false, JSON.stringify(r));
// Without --plan nothing new is said: the field is absent.
const bare = proveRed({ cwd: dir, base, cmd: CMD, tests: ['tests/sum.test.mjs'] });
assert.equal(bare.reason, 'not-red-at-base');
assert.equal('tests_only' in bare, false);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// --- 3: the example plan reads with the K11 words -----------------------------
test('3: the example plan gives no PLAN_STEP_ON_FAILURE_UNKNOWN', () => {
const r = validatePlan(join(ROOT, 'examples', '01-add-verbose-flag', 'plan.md'));
assert.deepEqual(r.warnings.filter((w) => w.code === 'PLAN_STEP_ON_FAILURE_UNKNOWN').map((w) => w.message), []);
});
// --- 5: the criteria runner strips NODE_TEST_CONTEXT ---------------------------
test('5: a nested `node --test` on a failing test is red even when NODE_TEST_CONTEXT is set', () => {
const dir = mkdtempSync(join(tmpdir(), 'retterunde-ctx-'));
const saved = process.env.NODE_TEST_CONTEXT;
try {
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'fail.test.mjs'), [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
"test('fails', () => { assert.equal(1, 2); });",
'',
].join('\n'));
process.env.NODE_TEST_CONTEXT = 'child-v8';
const [r] = runCriteria([{ label: 'C1', text: 'x', command: 'node --test tests/fail.test.mjs' }], { cwd: dir });
assert.equal(r.status, 'failed', JSON.stringify(r));
assert.notEqual(r.exitCode, 0);
} finally {
if (saved === undefined) delete process.env.NODE_TEST_CONTEXT;
else process.env.NODE_TEST_CONTEXT = saved;
rmSync(dir, { recursive: true, force: true });
}
});
// Guard against a reading of the example that counts nothing: it has steps.
test('3: the example plan is read (six or more steps)', () => {
const text = readFileSync(join(ROOT, 'examples', '01-add-verbose-flag', 'plan.md'), 'utf8');
assert.ok(validatePlanContent(text).parsed.steps.length >= 6);
});

View file

@ -0,0 +1,63 @@
// tests/lib/timing-under-load.test.mjs
// The two timing tests that went red under load in the wave sessions (order 10,
// point 2; operator 2026-10-09): P7 (post-edit-check) was killed by the hook
// helper's 5 s limit at load 95-177 and was 8/8 green alone; SC #14
// (otel-export) demands < 800 ms for ONE cold run.
// - The helper's kill limit is longer (HOOK_TIMEOUT_MS) and overridable per
// call. P7 checks the hook's answer, not its speed, so that weakens nothing.
// - SC #14 keeps its 800 ms limit and measures up to three runs, passing on
// the fastest. Load only ever ADDS time, so the fastest run is the best
// reading of the real cost: a real slowness is slow every time and still
// fails; one run slowed by a busy machine does not.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fastestWithin } from '../helpers/timing.mjs';
import { HOOK_TIMEOUT_MS, runHookWithEnv } from '../helpers/hook-helper.mjs';
/** A measure that returns the given durations in turn (no real waiting). */
const stub = (durations) => {
let i = 0;
return async () => durations[i++];
};
test('timing: a stub that is slow every time fails the limit (a real slowness is felled)', async () => {
const r = await fastestWithin(800, stub([900, 950, 1200]));
assert.equal(r.ok, false);
assert.deepEqual(r.runs, [900, 950, 1200]);
assert.equal(r.fastest, 900);
});
test('timing: one slow run of three is tolerated - the next run under the limit passes', async () => {
const r = await fastestWithin(800, stub([1500, 300, 2000]));
assert.equal(r.ok, true);
assert.deepEqual(r.runs, [1500, 300], 'stops at the first run under the limit');
});
test('timing: the limit itself is not loosened - a run AT the limit is not under it', async () => {
const r = await fastestWithin(800, stub([800, 800, 800]));
assert.equal(r.ok, false);
});
test('timing: a first run under the limit passes at once (one run on a quiet machine)', async () => {
const r = await fastestWithin(800, stub([120]));
assert.equal(r.ok, true);
assert.deepEqual(r.runs, [120]);
});
test('hook helper: the kill limit is far above 5 s by default, and a call can set its own', async () => {
assert.ok(HOOK_TIMEOUT_MS >= 30_000, `HOOK_TIMEOUT_MS = ${HOOK_TIMEOUT_MS}`);
const dir = mkdtempSync(join(tmpdir(), 'hook-helper-'));
try {
const slow = join(dir, 'slow-hook.mjs');
writeFileSync(slow, "setTimeout(() => { process.stdout.write('done'); process.exit(0); }, 400);\n");
const killed = await runHookWithEnv(slow, '{}', {}, { timeoutMs: 100 });
assert.notEqual(killed.code, 0, 'a per-call limit below the hook\'s run time kills it');
const done = await runHookWithEnv(slow, '{}', {});
assert.equal(done.code, 0);
assert.equal(done.stdout, 'done');
} finally { rmSync(dir, { recursive: true, force: true }); }
});