feat(plan): the Test first File line follows the 1.9 grammar [skip-docs]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3e1a811c60
commit
fc157c3ad5
3 changed files with 132 additions and 9 deletions
|
|
@ -578,15 +578,41 @@ export function stepTestsOnly(body, version) {
|
|||
|
||||
// A line of the `Test first:` field that names its test files (6.3.1's selector).
|
||||
const TEST_FIRST_FILE_LINE = /\bFile:\**[ \t]*`/i;
|
||||
// From plan_version 1.9: a line that opens with `File:` (after an optional
|
||||
// list marker and `**`), backticked or not.
|
||||
const TEST_FIRST_FILE_LINE_19 = /^\s*(?:[-*+]\s+)?(?:\*\*)?File:(?:\*\*)?/i;
|
||||
|
||||
/**
|
||||
* The step's test files: every file on the `Test first:` `File:` line. A line
|
||||
* counts as that line exactly when TEST_FIRST_FILE_LINE matches it. Below
|
||||
* plan_version 1.9 (or without a version) every backticked span on the line,
|
||||
* from the match on, that has the shape of a path is a test file (a call
|
||||
* `parse()`, an option, a URL is not), normalised and deduplicated. A
|
||||
* `Verifies:` line without a `File:` is never read. Every version reads this
|
||||
* way until the 1.9 branch exists.
|
||||
* plan_version 1.9: the rest of each `Test first:` `File:` line (after `File:`
|
||||
* and any `**`), one entry per line, in order. The one selector both
|
||||
* testFirstFiles and the plan validator's grammar check read.
|
||||
* @param {Map<string, {text: string}>} fields - parseStepFields(body)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function testFirstFileEntries(fields) {
|
||||
if (!fields || !fields.has('Test first')) return [];
|
||||
const out = [];
|
||||
for (const line of String(fields.get('Test first').text ?? '').split('\n')) {
|
||||
const at = TEST_FIRST_FILE_LINE_19.exec(line);
|
||||
if (at) out.push(line.slice(at[0].length).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's test files: every file on the `Test first:` `File:` line.
|
||||
*
|
||||
* Below plan_version 1.9 (or without a version) a line counts as that line
|
||||
* exactly when TEST_FIRST_FILE_LINE matches it, and every backticked span on
|
||||
* the line, from the match on, that has the shape of a path is a test file (a
|
||||
* call `parse()`, an option, a URL is not).
|
||||
*
|
||||
* From 1.9 a line counts when it opens with `File:` (testFirstFileEntries),
|
||||
* backticked or not, and its rest is one entry read by entryPaths: the
|
||||
* leading backticked paths are the test files, the rest is description.
|
||||
*
|
||||
* Normalised, in order, deduplicated. A `Verifies:` line without a `File:` is
|
||||
* never read.
|
||||
* @param {Map<string, {text: string}>} fields - parseStepFields(body)
|
||||
* @param {string|null} [version] - the plan's plan_version
|
||||
* @returns {string[]}
|
||||
|
|
@ -594,6 +620,12 @@ const TEST_FIRST_FILE_LINE = /\bFile:\**[ \t]*`/i;
|
|||
export function testFirstFiles(fields, version) {
|
||||
if (!fields || !fields.has('Test first')) return [];
|
||||
const out = [];
|
||||
if (versionAtLeast(version, '1.9')) {
|
||||
for (const entry of testFirstFileEntries(fields)) {
|
||||
for (const p of entryPaths(entry).files) if (!out.includes(p)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (const line of String(fields.get('Test first').text ?? '').split('\n')) {
|
||||
const at = TEST_FIRST_FILE_LINE.exec(line);
|
||||
if (!at) continue;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@
|
|||
// anything else, `none`, and an empty or absent `Files:` are
|
||||
// PLAN_STEP_PATH_NOT_FIRST; a leading span that is no path (`parse()`, an
|
||||
// option, a URL) is PLAN_STEP_PATH_NOT_A_PATH. Errors under strict,
|
||||
// warnings under --soft (checkPathGrammar).
|
||||
// warnings under --soft (checkPathGrammar). Each `Test first:` `File:`
|
||||
// line (a line that opens with `File:`, backticked or not) is one entry
|
||||
// under the same grammar and raises the same two codes.
|
||||
// - below 1.9, a step whose `Files:` reader (plan-schema `fieldPaths`) finds a
|
||||
// path outside backticks gets the warning PLAN_STEP_PATH_UNTICKED, under
|
||||
// --strict too; the plan is read and gated as before.
|
||||
|
|
@ -53,6 +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,
|
||||
} from '../parsers/plan-schema.mjs';
|
||||
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
|
|
@ -200,7 +203,8 @@ function checkRecovery(sections, opts, planVersion) {
|
|||
/**
|
||||
* plan_version 1.9: every `Files:` entry opens with its backticked path(s)
|
||||
* (plan-schema `entryPaths`). An absent `Files:` is read as empty text, so it
|
||||
* names no file.
|
||||
* names no file. Each `Test first:` `File:` line (plan-schema
|
||||
* `testFirstFileEntries`) is one entry under the same grammar.
|
||||
*/
|
||||
function checkPathGrammar(sections) {
|
||||
const out = [];
|
||||
|
|
@ -222,6 +226,23 @@ function checkPathGrammar(sections) {
|
|||
'the description follows (`` `src/a.mjs` - the parser ``). A step that changes no file names none.'));
|
||||
}
|
||||
}
|
||||
for (const entry of testFirstFileEntries(f)) {
|
||||
for (const i of entryPaths(entry).issues) {
|
||||
if (i.code === 'PLAN_STEP_PATH_NOT_A_PATH') {
|
||||
out.push(issue('PLAN_STEP_PATH_NOT_A_PATH',
|
||||
`Step ${s.n}: a \`Test first:\` \`File:\` line opens with a backticked span that is no path: ${entry}`,
|
||||
'plan_version 1.9: the leading backticked spans of the `File:` line are the test files; a call ' +
|
||||
'(`parse()`), an option or a URL is description and goes after the path.'));
|
||||
} else {
|
||||
out.push(issue('PLAN_STEP_PATH_NOT_FIRST',
|
||||
entry === ''
|
||||
? `Step ${s.n}: a \`Test first:\` \`File:\` line names no file`
|
||||
: `Step ${s.n}: a \`Test first:\` \`File:\` line does not open with a backticked path: ${entry}`,
|
||||
'plan_version 1.9: the `File:` line opens with its backticked test file(s), separated by commas or ' +
|
||||
'spaces; the description follows (`` File: `tests/a.test.mjs` (new) ``).'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { readFileSync } from 'node:fs';
|
|||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as m from '../../lib/parsers/plan-schema.mjs';
|
||||
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
|
@ -80,3 +82,71 @@ test('prose: the executor and the step helper name every file on the Test first
|
|||
assert.ok(text.includes(phrase), `${rel.join('/')} says "${phrase}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- plan_version 1.9 (plan step 5) ----------------------------------------
|
||||
// From 1.9 a `Test first:` `File:` line is every line that opens with `File:`
|
||||
// (after an optional list marker and `**`), backticked or not, and its rest is
|
||||
// one entry read by entryPaths: the leading backticked paths are the test
|
||||
// files, the rest is description. The validator puts the same grammar on it.
|
||||
|
||||
const PATH_CODES = ['PLAN_STEP_PATH_NOT_FIRST', 'PLAN_STEP_PATH_NOT_A_PATH'];
|
||||
// A plan of the given version whose first step has `Files:` `files` and the
|
||||
// `Test first:` `File:` line `fileLine` (both verbatim).
|
||||
const planWith = (version, files, fileLine) => buildPlan({
|
||||
version,
|
||||
steps: [{ filesRaw: files, testFirstRaw: fileLine }, { endToEnd: 'bash tests/e2e.sh' }],
|
||||
});
|
||||
|
||||
test('1.9: every leading backticked path on the File: line is a test file', () => {
|
||||
const body = step('`tests/a.test.mjs`, `tests/b.test.mjs`', [
|
||||
' - File: `tests/a.test.mjs`, `tests/b.test.mjs` (new)',
|
||||
' - Verifies: x',
|
||||
]);
|
||||
assert.deepEqual(testFirstFiles(body, '1.9'), ['tests/a.test.mjs', 'tests/b.test.mjs']);
|
||||
});
|
||||
|
||||
test('1.9: a backticked path in the description of the File: line is not a test file', () => {
|
||||
const body = step('`tests/a.test.mjs`', [
|
||||
' - File: `tests/a.test.mjs` (new), covers `tests/b.test.mjs`',
|
||||
' - Verifies: x',
|
||||
]);
|
||||
assert.deepEqual(testFirstFiles(body, '1.9'), ['tests/a.test.mjs']);
|
||||
// Below 1.9 the line is read as 6.3.1 + Step 3 read it: every path-shaped span.
|
||||
assert.deepEqual(testFirstFiles(body, '1.8'), ['tests/a.test.mjs', 'tests/b.test.mjs']);
|
||||
});
|
||||
|
||||
test('1.9: a code file named only in the File: line description keeps the step from being tests-only; in 1.8 it is tests-only', () => {
|
||||
const files = '`tests/a.test.mjs`, `checks/smoke.mjs`';
|
||||
const line = ' - File: `tests/a.test.mjs` (new), covers `checks/smoke.mjs`';
|
||||
const body = step(files, [line, ' - Verifies: x']);
|
||||
assert.equal(m.stepTestsOnly(body, '1.9').testsOnly, false, JSON.stringify(m.stepTestsOnly(body, '1.9')));
|
||||
assert.equal(m.stepTestsOnly(body, '1.8').testsOnly, true, JSON.stringify(m.stepTestsOnly(body, '1.8')));
|
||||
|
||||
const v19 = validatePlanContent(planWith('1.9', files, line));
|
||||
assert.ok(!v19.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v19.warnings));
|
||||
assert.deepEqual(v19.errors.filter((e) => PATH_CODES.includes(e.code)), [], JSON.stringify(v19.errors));
|
||||
const v18 = validatePlanContent(planWith('1.8', files, line));
|
||||
assert.ok(v18.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v18.warnings));
|
||||
});
|
||||
|
||||
test('1.9: a File: line that opens with `parse()` is refused as no path (error strict, warning soft)', () => {
|
||||
const text = planWith('1.9', '`tests/a.test.mjs`', ' - File: `parse()`');
|
||||
const strict = validatePlanContent(text);
|
||||
const e = strict.errors.find((i) => i.code === 'PLAN_STEP_PATH_NOT_A_PATH');
|
||||
assert.ok(e, JSON.stringify(strict.errors));
|
||||
assert.match(e.message, /Step 1/);
|
||||
assert.match(e.message, /Test first/);
|
||||
const soft = validatePlanContent(text, { strict: false });
|
||||
assert.ok(soft.warnings.some((w) => w.code === 'PLAN_STEP_PATH_NOT_A_PATH'), JSON.stringify(soft.warnings));
|
||||
assert.deepEqual(soft.errors.filter((i) => PATH_CODES.includes(i.code)), []);
|
||||
});
|
||||
|
||||
test('1.9: an unticked File: line gets PLAN_STEP_PATH_NOT_FIRST next to PLAN_STEP_NO_TEST_FIRST', () => {
|
||||
const r = validatePlanContent(planWith('1.9', '`tests/a.test.mjs`', ' - File: tests/a.test.mjs'));
|
||||
const codes = r.errors.map((e) => e.code);
|
||||
assert.ok(codes.includes('PLAN_STEP_PATH_NOT_FIRST'), codes.join(','));
|
||||
assert.ok(codes.includes('PLAN_STEP_NO_TEST_FIRST'), codes.join(','));
|
||||
const e = r.errors.find((i) => i.code === 'PLAN_STEP_PATH_NOT_FIRST');
|
||||
assert.match(e.message, /Step 1/);
|
||||
assert.match(e.message, /tests\/a\.test\.mjs/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue