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:
Kjell Tore Guttormsen 2026-10-12 16:09:25 +02:00
commit 3e1a811c60
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 330 additions and 16 deletions

View 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);
});