fix(plan): a 1.9 Test first names its test only on a line that opens with File:

The step check read File: anywhere on the Test first lines, so a 1.9 plan whose only File: stood mid-line validated while the executor, K6 and the red proof read no test file. From 1.9 checkSteps reads the test the way testFirstFiles does; below 1.9 it reads as before (decision 16). Found by the step 5 controller in /trekexecute --session 2; fixed on the operator's decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-12 17:44:23 +02:00
commit 3009c5eddb
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 61 additions and 8 deletions

View file

@ -55,7 +55,7 @@ import {
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
versionAtLeast, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnlyFromFields, entryPaths, untickedPaths,
testFirstFileEntries,
testFirstFileEntries, testFirstFiles,
} from '../parsers/plan-schema.mjs';
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
@ -70,18 +70,29 @@ export const DESIGN_SUBSECTIONS = Object.freeze([
]);
const BACKTICKED = /`[^`\n]+`/;
function checkSteps(sections) {
// From 1.9 the step names its test the way the executor reads it
// (testFirstFiles): only through a line that opens with `File:` and its
// backticked path, so a `File:` mid-line names no test. Below 1.9 the
// unanchored test stands (decision 16: older plans are read as before).
function checkSteps(sections, planVersion) {
const out = [];
let endToEnd = 0;
const grammar = versionAtLeast(planVersion, '1.9');
for (const s of sections) {
const f = parseStepFields(s.body);
const tf = f.get('Test first');
const names = tf && /\bFile:\**[ \t]*`[^`\n]+`/i.test(tf.text) && /\bVerifies:\**[ \t]*\S/i.test(tf.text);
const file = grammar
? testFirstFiles(f, planVersion).length > 0
: /\bFile:\**[ \t]*`[^`\n]+`/i.test(tf?.text ?? '');
const names = tf && file && /\bVerifies:\**[ \t]*\S/i.test(tf.text);
if (!names) {
out.push(issue(
'PLAN_STEP_NO_TEST_FIRST',
`Step ${s.n}: ${tf ? '`Test first:` does not name a File (backticked path) and what it Verifies' : 'no `Test first:` field'}`,
'plan_version 1.8 requires `Test first:` on every step, with `File:` and `Verifies:` sub-bullets.',
grammar
? 'plan_version 1.9 requires `Test first:` on every step, with a line that opens with `File:` and its ' +
'backticked path, and a `Verifies:` line.'
: 'plan_version 1.8 requires `Test first:` on every step, with `File:` and `Verifies:` sub-bullets.',
));
}
for (const name of OPTIONAL_STEP_FIELDS) {
@ -386,7 +397,7 @@ export function validatePlanContent(rawText, opts = {}) {
if (stepsFrom !== null) {
const appended = sections.filter((s) => Number(s.n) >= stepsFrom);
const stepIssues = versionAtLeast(planVersion, '1.8') ? [] : [
...checkSteps(appended).filter((i) => i.code !== 'PLAN_NO_END_TO_END'),
...checkSteps(appended, planVersion).filter((i) => i.code !== 'PLAN_NO_END_TO_END'),
...checkRunnable(text, appended, { cwd: opts.cwd, stepsOnly: true }),
];
for (const s of appended) {
@ -401,7 +412,7 @@ export function validatePlanContent(rawText, opts = {}) {
const ceremony = extractPlanCeremony(text);
if (versionAtLeast(planVersion, '1.8')) {
const v18 = [...checkSteps(sections), ...checkRunnable(text, sections, { cwd: opts.cwd })];
const v18 = [...checkSteps(sections, planVersion), ...checkRunnable(text, sections, { cwd: opts.cwd })];
if (!ceremony.valid) {
v18.push(issue('PLAN_CEREMONY_INVALID', `ceremony=${ceremony.raw}`,
`Allowed: ${CEREMONY_VALUES.join(' | ')} (absent = architectural).`));