feat(plan): read Files by the 1.9 grammar, warn on unticked paths below it [skip-docs]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8bf97cc834
commit
3e1a811c60
6 changed files with 330 additions and 16 deletions
|
|
@ -302,6 +302,9 @@ The strongest validation in the entire pipeline. Phase 5.5 (planning-orchestrato
|
|||
| `PLAN_STEP_REGRESSION_NOT_RUNNABLE` | K10, any version: a step's `Regression:` is not one command the allowlist runs (a pipe, `&&`, no backticks, an unknown runner) | One backticked runner call (`npm test`); a wider check goes in `bash tests/<script>.sh` |
|
||||
| `PLAN_STEP_ON_FAILURE_UNKNOWN` | K11, any version, warning: `On failure:` does not open with revert / retry / skip / escalate | Open with the word, then the specifics |
|
||||
| `PLAN_STEP_TESTS_ONLY` | Any version, warning: every file in the step's `Files:` is a test, so the step can never be proven red | Move the test into the step that builds the behaviour |
|
||||
| `PLAN_STEP_PATH_NOT_FIRST` | 1.9: a `Files:` entry does not open with a backticked path (also `none`, and an empty or absent `Files:`: the step names no file); error, a warning under `--soft` | Open each entry with its backticked path(s), then the description: `` `src/a.mjs` - the parser `` |
|
||||
| `PLAN_STEP_PATH_NOT_A_PATH` | 1.9: a `Files:` entry opens with a backticked span that is no path (a call `parse()`, an option, a URL); error, a warning under `--soft` | Put the path first; the span goes in the description |
|
||||
| `PLAN_STEP_PATH_UNTICKED` | Below 1.9, warning: the `Files:` reader found a path outside backticks; the plan is read as before (a warning under `--strict` too) | Backtick the path; from 1.9 an unticked path is refused |
|
||||
|
||||
**The plan gate (K3).** `node lib/validators/plan-gate.mjs <plan.md> [--json]` decides whether a plan may go on: **0** = it validates strict, its `## Critic Verdict` matches the current text, no BLOCKER is open, and every open MAJOR was accepted by the operator; **1** = stop, with `reasons` (`PLAN_GATE_INVALID_PLAN`, `PLAN_GATE_VERSION` — below plan_version 1.8, `PLAN_GATE_NO_VERDICT`, `PLAN_GATE_VERDICT_MALFORMED`, `PLAN_GATE_VERDICT_STALE`, `PLAN_GATE_OPEN_BLOCKER`, `PLAN_GATE_ROUNDS_EXHAUSTED`, `PLAN_GATE_OPEN_MAJOR` — rounds left: revise, `PLAN_GATE_OPEN_MAJOR_FINAL` — after the last round: ask the operator) and `warnings` (`PLAN_GATE_OPEN_MAJOR` for majors the operator accepted); **2** = could not read the plan. `--hash <plan.md>` prints the operative hash; Phase 9 takes it before the reviewers read the plan. `--record <plan.md> --reviewed <hash>` reads plan-review-dedup output on stdin (which lists the reviewers that delivered, `sources`) and writes the next round; exit 2 when a reviewer is missing or the plan changed since `--hash`; it refuses round 4 (exit 1, stop and ask) unless `--extra-round` follows the operator's yes, and refuses that flag before the cap is spent. Unrated findings count as blockers. The review log is excluded from the hash only up to its first other heading, so a step placed under it is hashed. `--accept-open <plan.md>` records the operator's "build as is" for the current round's open MAJORs (`accepted_open` in the verdict block, bound to the round and the plan hash; only after the last round; never for a BLOCKER; exit 1 when rounds are left, the verdict is stale, a blocker is open or no major is). Operator decision 2026-10-08: open majors used to be warnings, and a weakness the critics named was built and came back from `/trekreview` as MAJOR. plan-critic and scope-guardian rate on the review catalogue's tiers. The verdict is a trace of the review, not a signature.
|
||||
|
||||
|
|
|
|||
|
|
@ -136,8 +136,9 @@ export function validatePlanHeadings(text, opts = {}) {
|
|||
// ---- plan_version 1.8 ------------------------------------------------------
|
||||
|
||||
export const PLAN_VERSION_CURRENT = '1.8';
|
||||
// 1.7 plans stay valid: the 1.8 rules apply only from 1.8 on.
|
||||
export const PLAN_VERSIONS_SUPPORTED = Object.freeze(['1.7', '1.8']);
|
||||
// 1.7 plans stay valid: the 1.8 rules apply only from 1.8 on, the 1.9 `Files:`
|
||||
// grammar only from 1.9 on (see "plan_version 1.9" below).
|
||||
export const PLAN_VERSIONS_SUPPORTED = Object.freeze(['1.7', '1.8', '1.9']);
|
||||
|
||||
/** True when dotted version `v` is >= `min` (numeric per part; null → false). */
|
||||
export function versionAtLeast(v, min) {
|
||||
|
|
@ -461,6 +462,93 @@ export function fieldPaths(text) {
|
|||
return out.filter(Boolean).map(norm);
|
||||
}
|
||||
|
||||
// ---- plan_version 1.9 ------------------------------------------------------
|
||||
//
|
||||
// From plan_version 1.9 a step's `Files:` field has a grammar, so no reader has
|
||||
// to tell a path from a description in free text (decision 16, operator
|
||||
// 2026-10-11: only a 1.9 plan is refused; an older plan is read by fieldPaths
|
||||
// as before and gets one warning, PLAN_STEP_PATH_UNTICKED):
|
||||
//
|
||||
// - The entries are the field's first non-empty line and each list-item line
|
||||
// (`-`, `*`, `+`, `1.`, `1)`). Any other line continues the entry above it,
|
||||
// so a wrapped description line is no entry, and an empty first line before
|
||||
// a nested list is no entry either.
|
||||
// - An entry's files are the backticked spans it opens with, 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).
|
||||
// - An entry that does not open with a backticked span is refused
|
||||
// (PLAN_STEP_PATH_NOT_FIRST); so are `none` and a field that names no file.
|
||||
// A leading span that fails isPathShape (`parse()`, an option, a URL) is
|
||||
// refused as no path (PLAN_STEP_PATH_NOT_A_PATH).
|
||||
//
|
||||
// isPathShape is the test fieldPaths puts on a span in the leading position of
|
||||
// an entry (a path, a build file, or a capitalised name such as `LICENSE`), so
|
||||
// a 1.9 entry's leading span is judged exactly as the reader below 1.9 judges
|
||||
// one.
|
||||
|
||||
const LEADING_SPAN = /^`([^`\n]+)`/;
|
||||
|
||||
/** A backticked span in the leading position of an entry that names a file. */
|
||||
export function isPathShape(s) {
|
||||
const span = String(s ?? '').trim();
|
||||
return tickedPath(span) || BUILD_FILE.test(span) || BARE_NAME.test(span);
|
||||
}
|
||||
|
||||
/**
|
||||
* The files a 1.9 `Files:` field names, by the grammar above.
|
||||
* @param {string} text - the field's text (parseStepFields(body).get('Files').text)
|
||||
* @returns {{files: string[], issues: Array<{line: number, entry: string,
|
||||
* code: 'PLAN_STEP_PATH_NOT_FIRST'|'PLAN_STEP_PATH_NOT_A_PATH'}>}}
|
||||
* files normalised, in order, deduplicated; `line` is 1-based in `text`.
|
||||
*/
|
||||
export function entryPaths(text) {
|
||||
const files = [];
|
||||
const issues = [];
|
||||
const entries = [];
|
||||
String(text ?? '').split('\n').forEach((line, i) => {
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (item) entries.push({ line: i + 1, entry: line.slice(item[0].length).trim() });
|
||||
else if (entries.length === 0 && line.trim() !== '') entries.push({ line: i + 1, entry: line.trim() });
|
||||
});
|
||||
if (entries.length === 0) {
|
||||
issues.push({ line: 1, entry: '', code: 'PLAN_STEP_PATH_NOT_FIRST' });
|
||||
return { files, issues };
|
||||
}
|
||||
for (const { line, entry } of entries) {
|
||||
let rest = entry;
|
||||
let m = LEADING_SPAN.exec(rest);
|
||||
if (!m) {
|
||||
issues.push({ line, entry, code: 'PLAN_STEP_PATH_NOT_FIRST' });
|
||||
continue;
|
||||
}
|
||||
while (m) {
|
||||
const span = m[1].trim();
|
||||
if (isPathShape(span)) {
|
||||
const p = norm(span);
|
||||
if (!files.includes(p)) files.push(p);
|
||||
} else {
|
||||
issues.push({ line, entry, code: 'PLAN_STEP_PATH_NOT_A_PATH' });
|
||||
}
|
||||
rest = rest.slice(m[0].length);
|
||||
const sep = /^[\s,]+/.exec(rest);
|
||||
m = sep ? LEADING_SPAN.exec(rest.slice(sep[0].length)) : null;
|
||||
if (m) rest = rest.slice(sep[0].length);
|
||||
}
|
||||
}
|
||||
return { files, issues };
|
||||
}
|
||||
|
||||
/**
|
||||
* The paths fieldPaths reads from `text` that do not stand in backticks: the
|
||||
* paths below plan_version 1.9 that a 1.9 plan would refuse.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function untickedPaths(text) {
|
||||
const ticked = new Set([...String(text ?? '').matchAll(/`([^`\n]+)`/g)].map((m) => norm(m[1].trim())));
|
||||
return fieldPaths(text).filter((p) => !ticked.has(p));
|
||||
}
|
||||
|
||||
// A document or a data file outside tests/ is never the test here:
|
||||
// `docs/specs/api.md` and `api/specs/openapi.yaml` sit under a `specs/` folder,
|
||||
// which lib/util/test-path.mjs reads as tests for the floor guard. Under tests/
|
||||
|
|
@ -478,12 +566,14 @@ const UNDER_TESTS_RE = /(^|\/)(tests?|__tests__)\//;
|
|||
* trial 3, 2026-10-09: `not-red-at-base`, stop class 4). The plan validator
|
||||
* warns on it (PLAN_STEP_TESTS_ONLY) and red-proof.mjs reports it
|
||||
* (`tests_only`), so the executor sends the step up to the plan. A step
|
||||
* without `Files:` is not such a step.
|
||||
* without `Files:` is not such a step. From plan_version 1.9 `Files:` is read
|
||||
* by its grammar (entryPaths); below 1.9 (or without a version) by fieldPaths.
|
||||
* @param {string} body - the step's section (sliceSteps)
|
||||
* @param {string|null} [version] - the plan's plan_version
|
||||
* @returns {{testsOnly: boolean, files: string[]}}
|
||||
*/
|
||||
export function stepTestsOnly(body) {
|
||||
return stepTestsOnlyFromFields(parseStepFields(body));
|
||||
export function stepTestsOnly(body, version) {
|
||||
return stepTestsOnlyFromFields(parseStepFields(body), version);
|
||||
}
|
||||
|
||||
// A line of the `Test first:` field that names its test files (6.3.1's selector).
|
||||
|
|
@ -529,9 +619,10 @@ function stepTestFile(f, version) {
|
|||
}
|
||||
|
||||
/** `stepTestsOnly` for a caller that already holds the step's parsed fields. */
|
||||
export function stepTestsOnlyFromFields(f) {
|
||||
const files = f.has('Files') ? fieldPaths(f.get('Files').text) : [];
|
||||
return { testsOnly: files.length > 0 && files.every(stepTestFile(f)), files };
|
||||
export function stepTestsOnlyFromFields(f, version) {
|
||||
const text = f.has('Files') ? f.get('Files').text : null;
|
||||
const files = text === null ? [] : versionAtLeast(version, '1.9') ? entryPaths(text).files : fieldPaths(text);
|
||||
return { testsOnly: files.length > 0 && files.every(stepTestFile(f, version)), files };
|
||||
}
|
||||
|
||||
// ---- sections (fence-aware) ------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -32,14 +32,27 @@
|
|||
// /trekexecute reads; any other opening is a warning.
|
||||
// - A step whose `Files:` are all tests (plan-schema `stepTestsOnly`) is the
|
||||
// warning PLAN_STEP_TESTS_ONLY: its tests pass at its start point, so it can
|
||||
// never be proven red (voyage-lekerepo trial 3, 2026-10-09).
|
||||
// never be proven red (voyage-lekerepo trial 3, 2026-10-09). The check reads
|
||||
// `Files:` by the plan's version (the 1.9 grammar from 1.9 on).
|
||||
//
|
||||
// plan_version 1.9 rules (decision 16, operator 2026-10-11: an unticked path in
|
||||
// `Files:` is refused only from 1.9; older plans are read as before):
|
||||
// - every `Files:` entry opens with its backticked path(s); the rest of the
|
||||
// entry is description (plan-schema `entryPaths`). An entry that opens with
|
||||
// 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).
|
||||
// - 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.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import {
|
||||
sliceSteps, validatePlanHeadings, extractPlanVersion, extractPlanCeremony,
|
||||
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
|
||||
versionAtLeast, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
|
||||
ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnlyFromFields,
|
||||
ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnlyFromFields, entryPaths, untickedPaths,
|
||||
} from '../parsers/plan-schema.mjs';
|
||||
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
|
|
@ -146,7 +159,7 @@ function checkRunnable(text, sections, opts) {
|
|||
* checkpoint" in prose that nothing enforced (voyage-lekerepo trial 2,
|
||||
* 2026-10-08). `Regression:` is that command, checked like `Verify:`.
|
||||
*/
|
||||
function checkRecovery(sections, opts) {
|
||||
function checkRecovery(sections, opts, planVersion) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
for (const s of sections) {
|
||||
|
|
@ -165,7 +178,7 @@ function checkRecovery(sections, opts) {
|
|||
`Step ${s.n}: \`On failure:\` does not open with a recovery word the executor reads`,
|
||||
`Open with one of ${ON_FAILURE_ACTIONS.join(' | ')}, then the specifics (\`retry once, then escalate\`).`));
|
||||
}
|
||||
if (stepTestsOnlyFromFields(f).testsOnly) {
|
||||
if (stepTestsOnlyFromFields(f, planVersion).testsOnly) {
|
||||
// The run-the-thing script alone is such a step too: `End-to-end:` rides on
|
||||
// the last step that builds behaviour (release review of 6.3.0, 2026-10-10).
|
||||
const e2e = f.has('End-to-end')
|
||||
|
|
@ -184,6 +197,35 @@ function checkRecovery(sections, opts) {
|
|||
return { errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function checkPathGrammar(sections) {
|
||||
const out = [];
|
||||
for (const s of sections) {
|
||||
const f = parseStepFields(s.body);
|
||||
const { issues } = entryPaths(f.has('Files') ? f.get('Files').text : '');
|
||||
for (const i of issues) {
|
||||
if (i.code === 'PLAN_STEP_PATH_NOT_A_PATH') {
|
||||
out.push(issue('PLAN_STEP_PATH_NOT_A_PATH',
|
||||
`Step ${s.n}: a \`Files:\` entry opens with a backticked span that is no path: ${i.entry}`,
|
||||
'plan_version 1.9: the leading backticked spans of an entry are its 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',
|
||||
i.entry === ''
|
||||
? `Step ${s.n}: \`Files:\` names no file`
|
||||
: `Step ${s.n}: a \`Files:\` entry does not open with a backticked path: ${i.entry}`,
|
||||
'plan_version 1.9: each `Files:` entry opens with its backticked path(s), separated by commas or spaces; ' +
|
||||
'the description follows (`` `src/a.mjs` - the parser ``). A step that changes no file names none.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Table body rows (skips the header and the |---| separator). */
|
||||
function tableRows(text) {
|
||||
const rows = text.split('\n').filter(l => l.trim().startsWith('|'));
|
||||
|
|
@ -289,11 +331,29 @@ export function validatePlanContent(rawText, opts = {}) {
|
|||
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is ${PLAN_VERSION_CURRENT}`));
|
||||
}
|
||||
|
||||
const recovery = checkRecovery(sections, { cwd: opts.cwd });
|
||||
const recovery = checkRecovery(sections, { cwd: opts.cwd }, planVersion);
|
||||
if (strict) errors.push(...recovery.errors);
|
||||
else warnings.push(...recovery.errors);
|
||||
warnings.push(...recovery.warnings);
|
||||
|
||||
// The `Files:` grammar (decision 16): refused from 1.9, a warning below it.
|
||||
if (versionAtLeast(planVersion, '1.9')) {
|
||||
const grammar = checkPathGrammar(sections);
|
||||
if (strict) errors.push(...grammar);
|
||||
else warnings.push(...grammar);
|
||||
} else {
|
||||
for (const s of sections) {
|
||||
const f = parseStepFields(s.body);
|
||||
const unticked = f.has('Files') ? [...new Set(untickedPaths(f.get('Files').text))] : [];
|
||||
if (unticked.length > 0) {
|
||||
warnings.push(issue('PLAN_STEP_PATH_UNTICKED',
|
||||
`Step ${s.n}: \`Files:\` names ${unticked.join(', ')} outside backticks; read as before`,
|
||||
'From plan_version 1.9 an unticked path in `Files:` is refused: each entry opens with its backticked ' +
|
||||
'path(s), and the description follows.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Steps a run appended (the review loop's remediation steps, /trekexecute
|
||||
// Phase 7.2) must be runnable steps whatever the plan's version: below 1.8
|
||||
// the step rules never ran, and a review step whose Verify is "re-run
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
// Builds plan_version 1.8 plans for the plan-contract tests (K3 gate, K4 step
|
||||
// fields, the design part, the scope sweep). One builder, many variants, so a
|
||||
// test states only the difference it is about.
|
||||
//
|
||||
// Raw options on step() write a field the generated form cannot (the
|
||||
// plan_version 1.9 grammar tests): `filesRaw` is the `Files:` value written
|
||||
// verbatim after `- **Files:** `; `testFirstRaw` is the whole `Test first:`
|
||||
// `File:` line written verbatim in place of ` - File: `<test>` (new)`. Without
|
||||
// them the output is unchanged.
|
||||
|
||||
function manifest(paths) {
|
||||
return [
|
||||
|
|
@ -25,7 +31,7 @@ export function step(n, opts = {}) {
|
|||
const lines = [
|
||||
`### Step ${n}: ${opts.title ?? `Build module ${n}`}`,
|
||||
'',
|
||||
`- **Files:** \`${file}\``,
|
||||
opts.filesRaw !== undefined ? `- **Files:** ${opts.filesRaw}` : `- **Files:** \`${file}\``,
|
||||
`- **Changes:** Add \`run${n}()\` to \`${file}\`. (new file)`,
|
||||
];
|
||||
if (opts.interfaceIn !== undefined) lines.push(`- **Interface in:** ${opts.interfaceIn}`);
|
||||
|
|
@ -34,7 +40,7 @@ export function step(n, opts = {}) {
|
|||
if (opts.testFirst !== false) {
|
||||
lines.push(
|
||||
'- **Test first:**',
|
||||
` - File: \`${test}\` (new)`,
|
||||
opts.testFirstRaw !== undefined ? opts.testFirstRaw : ` - File: \`${test}\` (new)`,
|
||||
` - Verifies: run${n}() returns the value the next step reads`,
|
||||
' - Pattern: `tests/lib/plan-schema.test.mjs`',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ test('review 630 r3: the plan validator hands its parsed fields to the tests-onl
|
|||
assert.equal(typeof schema.stepTestsOnlyFromFields, 'function');
|
||||
const body = '- **Files:** `tests/a.test.mjs`\n- **Changes:** x\n';
|
||||
assert.deepEqual(schema.stepTestsOnlyFromFields(schema.parseStepFields(body)), stepTestsOnly(body));
|
||||
assert.match(read('lib/validators/plan-validator.mjs'), /stepTestsOnlyFromFields\(f\)/);
|
||||
assert.match(read('lib/validators/plan-validator.mjs'), /stepTestsOnlyFromFields\(f, planVersion\)/);
|
||||
});
|
||||
|
||||
test('review 630 r3: skill frontmatter - a value starting with # is a hazard, an escaped backslash closes a quote', async () => {
|
||||
|
|
|
|||
154
tests/validators/plan-version-1-9.test.mjs
Normal file
154
tests/validators/plan-version-1-9.test.mjs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// tests/validators/plan-version-1-9.test.mjs
|
||||
// plan_version 1.9: each `Files:` entry opens with its backticked path(s), and
|
||||
// everything after the first other text is description that is never read.
|
||||
// An entry that opens with anything else is refused (an error under --strict,
|
||||
// a warning under --soft). Decision 16 (operator 11.10): only a 1.9 plan is
|
||||
// refused; a plan below 1.9 is read as before and gets one warning,
|
||||
// PLAN_STEP_PATH_UNTICKED, for a path its reader found outside backticks.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import * as schema from '../../lib/parsers/plan-schema.mjs';
|
||||
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
|
||||
import { gatePlan, recordVerdict, planHash } from '../../lib/validators/plan-gate.mjs';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
|
||||
const errCodes = (r) => r.errors.map((e) => e.code);
|
||||
const warnCodes = (r) => r.warnings.map((w) => w.code);
|
||||
const PATH_CODES = ['PLAN_STEP_PATH_NOT_FIRST', 'PLAN_STEP_PATH_NOT_A_PATH'];
|
||||
const pathIssues = (list) => list.filter((i) => PATH_CODES.includes(i.code));
|
||||
// A 1.9 plan whose first step's `Files:` value is `files` (verbatim).
|
||||
const plan19 = (files) => buildPlan({ version: '1.9', steps: [{ filesRaw: files }, { endToEnd: 'bash tests/e2e.sh' }] });
|
||||
const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }];
|
||||
const passedCritic = (text) => recordVerdict(text, { findings: [], sources: BOTH, dedup_stats: { total_in: 0, total_out: 0 } },
|
||||
{ reviewed: planHash(text) });
|
||||
|
||||
function entryPaths(text) {
|
||||
assert.equal(typeof schema.entryPaths, 'function', 'plan-schema exports entryPaths');
|
||||
return schema.entryPaths(text);
|
||||
}
|
||||
|
||||
test('1.9: a plan_version 1.9 plan is supported - no PLAN_VERSION_MISMATCH, and the builder plan validates strict', () => {
|
||||
const r = validatePlanContent(buildPlan({ version: '1.9' }));
|
||||
assert.equal(r.warnings.find((w) => w.code === 'PLAN_VERSION_MISMATCH'), undefined, JSON.stringify(r.warnings));
|
||||
assert.deepEqual(r.errors, [], JSON.stringify(r.errors, null, 2));
|
||||
assert.equal(r.parsed.planVersion, '1.9');
|
||||
});
|
||||
|
||||
test('1.9: a Files entry without a leading backticked path is an error under --strict and a warning under --soft', () => {
|
||||
const text = plan19('src/mod1.mjs - the parser');
|
||||
const strict = validatePlanContent(text);
|
||||
assert.equal(strict.valid, false);
|
||||
const e = strict.errors.find((i) => i.code === 'PLAN_STEP_PATH_NOT_FIRST');
|
||||
assert.ok(e, errCodes(strict).join(','));
|
||||
assert.match(e.message, /Step 1/);
|
||||
assert.match(e.message, /src\/mod1\.mjs - the parser/);
|
||||
const soft = validatePlanContent(text, { strict: false });
|
||||
assert.ok(warnCodes(soft).includes('PLAN_STEP_PATH_NOT_FIRST'), warnCodes(soft).join(','));
|
||||
assert.deepEqual(pathIssues(soft.errors), []);
|
||||
});
|
||||
|
||||
test('1.9: "`tests/a.test.mjs` - covers `src/parse.mjs`" names only the test file', () => {
|
||||
const r = entryPaths('`tests/a.test.mjs` - covers `src/parse.mjs`');
|
||||
assert.deepEqual(r.files, ['tests/a.test.mjs']);
|
||||
assert.deepEqual(r.issues, []);
|
||||
const v = validatePlanContent(plan19('`tests/a.test.mjs` - covers `src/parse.mjs`'));
|
||||
assert.deepEqual(pathIssues(v.errors), [], JSON.stringify(v.errors));
|
||||
});
|
||||
|
||||
test('1.9: several leading paths separated by commas or whitespace are all files', () => {
|
||||
assert.deepEqual(entryPaths('`src/a.mjs`, `./src/b.mjs` `tests/a.test.mjs` *(new)*, `src/c.mjs`').files,
|
||||
['src/a.mjs', 'src/b.mjs', 'tests/a.test.mjs']);
|
||||
});
|
||||
|
||||
test('1.9: a wrapped description line is not an entry', () => {
|
||||
const text = '`src/a.mjs` - the parser, which\n reads src/b.mjs and the CLI options';
|
||||
const r = entryPaths(text);
|
||||
assert.deepEqual(r.files, ['src/a.mjs']);
|
||||
assert.deepEqual(r.issues, []);
|
||||
const v = validatePlanContent(plan19(text));
|
||||
assert.deepEqual(pathIssues(v.errors), [], JSON.stringify(v.errors));
|
||||
});
|
||||
|
||||
test('1.9: an empty first line with a nested list is not an entry; each list item is one', () => {
|
||||
const text = '\n - `src/a.mjs` - the parser\n - `tests/a.test.mjs` (new)';
|
||||
const r = entryPaths(text);
|
||||
assert.deepEqual(r.files, ['src/a.mjs', 'tests/a.test.mjs']);
|
||||
assert.deepEqual(r.issues, []);
|
||||
const v = validatePlanContent(plan19(text));
|
||||
assert.deepEqual(pathIssues(v.errors), [], JSON.stringify(v.errors));
|
||||
// A list item that does not open with a path is refused.
|
||||
const bad = entryPaths('\n - `src/a.mjs`\n - the CLI in src/cli.mjs');
|
||||
assert.deepEqual(bad.files, ['src/a.mjs']);
|
||||
assert.deepEqual(bad.issues.map((i) => i.code), ['PLAN_STEP_PATH_NOT_FIRST']);
|
||||
assert.match(bad.issues[0].entry, /the CLI in src\/cli\.mjs/);
|
||||
});
|
||||
|
||||
test('1.9: a leading `parse()` is refused as no path', () => {
|
||||
const r = entryPaths('`parse()` - the parser entry');
|
||||
assert.deepEqual(r.files, []);
|
||||
assert.deepEqual(r.issues.map((i) => i.code), ['PLAN_STEP_PATH_NOT_A_PATH']);
|
||||
const v = validatePlanContent(plan19('`parse()` - the parser entry'));
|
||||
assert.ok(errCodes(v).includes('PLAN_STEP_PATH_NOT_A_PATH'), errCodes(v).join(','));
|
||||
});
|
||||
|
||||
test('1.9: the shape test is the reader\'s leading-position test', () => {
|
||||
assert.equal(typeof schema.isPathShape, 'function', 'plan-schema exports isPathShape');
|
||||
for (const s of ['LICENSE', 'Makefile', 'src/a.mjs', 'package.json', 'docs/']) assert.equal(schema.isPathShape(s), true, s);
|
||||
for (const s of ['parse()', '-x', 'https://example.com/a.md', 'format']) assert.equal(schema.isPathShape(s), false, s);
|
||||
});
|
||||
|
||||
test('1.9: `none` and an absent `Files:` are refused', () => {
|
||||
const none = entryPaths('none');
|
||||
assert.deepEqual(none.files, []);
|
||||
assert.deepEqual(none.issues.map((i) => i.code), ['PLAN_STEP_PATH_NOT_FIRST']);
|
||||
const empty = entryPaths('');
|
||||
assert.deepEqual(empty.files, []);
|
||||
assert.deepEqual(empty.issues.map((i) => i.code), ['PLAN_STEP_PATH_NOT_FIRST']);
|
||||
assert.ok(errCodes(validatePlanContent(plan19('none'))).includes('PLAN_STEP_PATH_NOT_FIRST'));
|
||||
const absent = buildPlan({ version: '1.9' }).replace(/^- \*\*Files:\*\* `src\/mod1\.mjs`\n/m, '');
|
||||
assert.ok(!/Files:\*\* `src\/mod1\.mjs`/.test(absent), 'the fixture drops Step 1\'s Files line');
|
||||
const r = validatePlanContent(absent);
|
||||
const e = r.errors.find((i) => i.code === 'PLAN_STEP_PATH_NOT_FIRST');
|
||||
assert.ok(e, errCodes(r).join(','));
|
||||
assert.match(e.message, /Step 1/);
|
||||
});
|
||||
|
||||
test('1.9: the tests-only check reads Files by the grammar', () => {
|
||||
const body = '- **Files:** `tests/a.test.mjs` - covers `src/parse.mjs`\n- **Changes:** x\n';
|
||||
assert.deepEqual(schema.stepTestsOnly(body, '1.9'), { testsOnly: true, files: ['tests/a.test.mjs'] });
|
||||
});
|
||||
|
||||
test('below 1.9: an unticked path gets PLAN_STEP_PATH_UNTICKED (a warning under --strict too), the 6.3.1 paths, and the gate passes', () => {
|
||||
const files = '`tests/mod1.test.mjs`, src/mod1.mjs';
|
||||
const text = buildPlan({ steps: [{ filesRaw: files }, { endToEnd: 'bash tests/e2e.sh' }] });
|
||||
assert.ok(text.includes(`- **Files:** ${files}\n`), 'the builder writes the raw Files value');
|
||||
const r = validatePlanContent(text);
|
||||
assert.deepEqual(r.errors, [], JSON.stringify(r.errors, null, 2));
|
||||
const w = r.warnings.filter((i) => i.code === 'PLAN_STEP_PATH_UNTICKED');
|
||||
assert.equal(w.length, 1, warnCodes(r).join(','));
|
||||
assert.match(w[0].message, /Step 1/);
|
||||
assert.match(w[0].message, /src\/mod1\.mjs/);
|
||||
assert.equal(r.warnings.filter((i) => PATH_CODES.includes(i.code)).length, 0);
|
||||
const soft = validatePlanContent(text, { strict: false });
|
||||
assert.ok(warnCodes(soft).includes('PLAN_STEP_PATH_UNTICKED'));
|
||||
// Read as before: the same paths 6.3.1 reads.
|
||||
assert.equal(typeof schema.untickedPaths, 'function', 'plan-schema exports untickedPaths');
|
||||
assert.deepEqual(schema.untickedPaths(files), ['src/mod1.mjs']);
|
||||
assert.deepEqual(schema.fieldPaths(files), ['tests/mod1.test.mjs', 'src/mod1.mjs']);
|
||||
assert.deepEqual(schema.stepTestsOnly(`- **Files:** ${files}\n`).files, ['tests/mod1.test.mjs', 'src/mod1.mjs']);
|
||||
// The gate passes it: a warning never stops a plan.
|
||||
const rec = passedCritic(text);
|
||||
assert.equal(rec.code, 0, rec.message);
|
||||
const g = gatePlan(rec.text);
|
||||
assert.equal(g.code, 0, JSON.stringify(g.reasons));
|
||||
});
|
||||
|
||||
test('below 1.9: a plan whose Files are all backticked gets no new warning', () => {
|
||||
const r = validatePlanContent(buildPlan());
|
||||
assert.equal(r.warnings.find((w) => w.code === 'PLAN_STEP_PATH_UNTICKED'), undefined, JSON.stringify(r.warnings));
|
||||
// The same unticked step in a 1.9 plan is refused rather than warned about.
|
||||
const v = validatePlanContent(plan19('src/mod1.mjs'));
|
||||
assert.ok(errCodes(v).includes('PLAN_STEP_PATH_NOT_FIRST'), errCodes(v).join(','));
|
||||
assert.equal(v.warnings.find((w) => w.code === 'PLAN_STEP_PATH_UNTICKED'), undefined);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue