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:
parent
64e6771c8b
commit
3009c5eddb
3 changed files with 61 additions and 8 deletions
|
|
@ -273,7 +273,7 @@ prose form (relaxed in S26 so the template's prose emission parses).
|
|||
8. **plan_version 1.9 only** (1.8 and 1.7 plans are read as before, with one warning, `PLAN_STEP_PATH_UNTICKED`, for a path their reader found outside backticks; decision 16, operator 2026-10-11) — the file first on every line:
|
||||
- the entries of a step's `Files:` field are its first non-empty line and each list-item line (`-`, `*`, `+`, `1.`, `1)`); any other line continues the entry above it;
|
||||
- each entry opens with its backticked path(s), separated only by commas or whitespace; everything from the first other text on is description and is never read (`` `a.test.mjs` - covers `src/parse.mjs` `` names one file), so every path stands before any annotation (`` `src/a.mjs`, `src/b.mjs` (new) ``, never `` `src/a.mjs` (new), `src/b.mjs` ``);
|
||||
- each `Test first:` `File:` line is one entry under the same grammar (several test files share one line: `` File: `a.test.mjs`, `b.test.mjs` (new) ``);
|
||||
- each `Test first:` `File:` line is one entry under the same grammar (several test files share one line: `` File: `a.test.mjs`, `b.test.mjs` (new) ``). A line counts only when `File:` opens it (after a list marker or `**`); a step whose `Test first:` has no such line with a backticked path names no test and is `PLAN_STEP_NO_TEST_FIRST` (`File:` mid-line is description, as the executor reads it);
|
||||
- an entry that does not open with a backticked span, `none`, and an empty or absent `Files:` are `PLAN_STEP_PATH_NOT_FIRST` (a 1.9 step always names a file); a leading span that is no path (a call, an option, a URL) is `PLAN_STEP_PATH_NOT_A_PATH`. Both are errors under `--strict`, warnings under `--soft`;
|
||||
- K6 (`lib/verification/plan-scope-sweep.mjs`) follows the grammar from 1.9: it reads a 1.9 plan's `Files:` and `Test first:` `File:` line by it (plan-schema `entryPaths` / `testFirstFiles`), and so does the red proof's `tests_only` reading (`lib/verification/red-proof.mjs`); below 1.9 both keep their earlier readers.
|
||||
|
||||
|
|
@ -293,7 +293,7 @@ The strongest validation in the entire pipeline. Phase 5.5 (planning-orchestrato
|
|||
| `PLAN_VERSION_MISMATCH` | `plan_version` other than 1.7 / 1.8 / 1.9 | Warning only; planner should bump |
|
||||
| `PLAN_NO_VERSION` | No `plan_version` found | Warning only; add the header line |
|
||||
| `PLAN_NOT_FOUND` / `PLAN_READ_ERROR` | The plan file is missing or unreadable | Check the path |
|
||||
| `PLAN_STEP_NO_TEST_FIRST` | 1.8: a step lacks `Test first:` with File + Verifies | Add the test the step makes pass |
|
||||
| `PLAN_STEP_NO_TEST_FIRST` | 1.8: a step lacks `Test first:` with File + Verifies; 1.9: also when no line opens with `File:` and its backticked path | Add the test the step makes pass |
|
||||
| `PLAN_NO_END_TO_END` | 1.8: no step carries `End-to-end:` with a command | Add the "run the thing" step |
|
||||
| `PLAN_STEP_FIELD_EMPTY` | 1.8: `Interface in/out:` or `Review focus:` present but empty | Fill or remove it |
|
||||
| `PLAN_VERIFICATION_NOT_RUNNABLE` | 1.8: a `## Verification` criterion has no command, or one the criteria runner's allowlist never runs | Name a test runner, or wrap the check as `bash tests/<script>.sh` |
|
||||
|
|
|
|||
|
|
@ -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).`));
|
||||
|
|
|
|||
|
|
@ -227,3 +227,45 @@ test('1.9: the red proof\'s tests_only reads Files by the grammar (CLI, temp git
|
|||
}
|
||||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// The validator reads the step's test the way the executor does (S139): from
|
||||
// 1.9 `Test first:` names its test only through a line that opens with
|
||||
// `File:` and its backticked path (testFirstFiles), so a `File:` mid-line is
|
||||
// no test. Below 1.9 the step check is read as before (decision 16).
|
||||
const planTestFirst19 = (version, line) =>
|
||||
buildPlan({ version, steps: [{ testFirstRaw: line }, { endToEnd: 'bash tests/e2e.sh' }] });
|
||||
|
||||
test('1.9: a Test first whose File: stands mid-line names no test - PLAN_STEP_NO_TEST_FIRST under --strict, a warning under --soft', () => {
|
||||
for (const line of [
|
||||
' - see File: `tests/mod1.test.mjs` for the test',
|
||||
' - Pattern: File: `tests/mod1.test.mjs`',
|
||||
]) {
|
||||
const text = planTestFirst19('1.9', line);
|
||||
assert.ok(text.includes(`${line}\n`), 'the builder writes the raw File line');
|
||||
assert.deepEqual(schema.testFirstFiles(schema.parseStepFields(text.split('### Step 1:')[1].split('### Step 2:')[0]), '1.9'), [],
|
||||
'the executor reads no test file here');
|
||||
const r = validatePlanContent(text, { strict: true });
|
||||
assert.equal(r.valid, false, `${line}: ${errCodes(r).join(',')}`);
|
||||
const no = r.errors.filter((e) => e.code === 'PLAN_STEP_NO_TEST_FIRST');
|
||||
assert.equal(no.length, 1, `${line}: ${errCodes(r).join(',')}`);
|
||||
assert.match(no[0].message, /Step 1/);
|
||||
const soft = validatePlanContent(text, { strict: false });
|
||||
assert.ok(warnCodes(soft).includes('PLAN_STEP_NO_TEST_FIRST'), `${line}: ${warnCodes(soft).join(',')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('1.9: a File: line that opens the line names its test - no PLAN_STEP_NO_TEST_FIRST', () => {
|
||||
for (const line of [
|
||||
' - File: `tests/mod1.test.mjs` (new)',
|
||||
' - **File:** `tests/mod1.test.mjs` *(new)*',
|
||||
' - File: `tests/mod1.test.mjs`, `tests/mod1b.test.mjs` (new)',
|
||||
]) {
|
||||
const r = validatePlanContent(planTestFirst19('1.9', line), { strict: true });
|
||||
assert.equal(r.errors.find((e) => e.code === 'PLAN_STEP_NO_TEST_FIRST'), undefined, `${line}: ${errCodes(r).join(',')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('below 1.9: the step check reads a mid-line File: as before', () => {
|
||||
const r = validatePlanContent(planTestFirst19('1.8', ' - see File: `tests/mod1.test.mjs` for the test'), { strict: true });
|
||||
assert.equal(r.errors.find((e) => e.code === 'PLAN_STEP_NO_TEST_FIRST'), undefined, errCodes(r).join(','));
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue