feat(verification): K6 and the red proof read a 1.9 plan by its grammar [skip-docs]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-12 16:29:58 +02:00
commit d14a49b92d
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 90 additions and 6 deletions

View file

@ -10,7 +10,13 @@
// changed = `git diff --name-only --no-renames <merge-base>` (committed AND
// uncommitted tracked changes) + untracked, non-ignored files
// planned = every step's `Files:` paths, its `Test first:` File, and its
// manifest `expected_paths`
// manifest `expected_paths`. From plan_version 1.9 `Files:` and the
// `Test first:` `File:` line are read by the 1.9 grammar
// (plan-schema entryPaths / testFirstFiles): the leading backticked
// paths of each entry, never a path its description mentions.
// Below 1.9 (or without a version) K6 keeps its own reader,
// unchanged (operator 2026-10-11). It never reads `Files:` through
// the tests-only reader (plan-schema fieldPaths).
// process = PROCESS_FILES (the plan itself, its project directory, Voyage's
// own planning areas) + any `--process` entries
// outside = changed − planned − process. It must be empty.
@ -32,7 +38,7 @@
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve, relative, dirname, isAbsolute, sep } from 'node:path';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { sliceSteps, parseStepFields, entryPaths, testFirstFiles, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
import { parseManifest } from '../parsers/manifest-yaml.mjs';
import { isMain } from '../util/is-main.mjs';
@ -59,9 +65,16 @@ const norm = p => p.replace(/\\/g, '/').replace(/^\.\//, '');
/** Every path the plan names: Files, Test first File, manifest expected_paths. */
export function plannedPaths(planText) {
const out = new Set();
const version = extractPlanVersion(planText);
const grammar = versionAtLeast(version, '1.9');
for (const s of sliceSteps(planText)) {
const f = parseStepFields(s.body);
const files = f.get('Files');
if (grammar) {
// plan_version 1.9: the grammar decides what each entry names.
if (files) for (const p of entryPaths(files.text).files) out.add(norm(p));
for (const p of testFirstFiles(f, version)) out.add(norm(p));
}
// v6.2.0's own reading, kept as it was (operator 2026-10-11): every
// backticked span that looks like a path, and plain words only when the
// field has none. A field that names its paths in backticks plans those,
@ -70,14 +83,14 @@ export function plannedPaths(planText) {
// (plan-schema.mjs fieldPaths) dropped paths v6.2.0 planned and planned
// directories a description named, so K6 has its own reader again; the
// two answer different questions (what may change; is every named file a test).
if (files) {
if (files && !grammar) {
const ticked = pathsIn(files.text);
// Older plans write bare paths: `- Files: a.ts, b.ts`.
const bare = ticked.length ? [] : files.text.split(/[,\s]+/).filter(looksLikePath);
for (const p of [...ticked, ...bare]) out.add(norm(p));
}
const tf = f.get('Test first');
if (tf) {
if (tf && !grammar) {
for (const line of tf.text.split('\n')) {
if (/\bFile:/i.test(line)) for (const p of pathsIn(line)) out.add(norm(p));
}

View file

@ -61,7 +61,7 @@ import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { DEFAULT_TIMEOUT_MS, allowedCommand, runCommand, screenCommand, stoppedBy } from './criteria-runner.mjs';
import { commandEnv } from '../util/command-env.mjs';
import { parseStepFields, sliceSteps, stepTestsOnlyFromFields } from '../parsers/plan-schema.mjs';
import { extractPlanVersion, parseStepFields, sliceSteps, stepTestsOnlyFromFields } from '../parsers/plan-schema.mjs';
import { isMain } from '../util/is-main.mjs';
// The one timeout, defined in criteria-runner.mjs (release review of 6.3.0, round 7).
@ -128,7 +128,7 @@ function testsOnly(o) {
catch (e) { return { tests_only: null, tests_only_note: `cannot read the plan: ${e.message}` }; }
const step = sliceSteps(text).find((s) => Number(s.n) === Number(o.step));
if (!step) return { tests_only: null, tests_only_note: `no "### Step ${o.step}:" in the plan` };
return { tests_only: stepTestsOnlyFromFields(parseStepFields(step.body)).testsOnly };
return { tests_only: stepTestsOnlyFromFields(parseStepFields(step.body), extractPlanVersion(text)).testsOnly };
}
// A program that could not start (a spawn error: not installed), or one this