R8 of cleanup R-a (plan .claude/projects/2026-11-07-cleanup-r-a, appendix A). The live tests of the round files move into the test named after their source; each moved test was seen red under a mutation of its source. New: tests/lib/lib-imports.test.mjs, tests/lib/timing.test.mjs (from timing-under-load). The [#1] --gates rule and shim-line pins move into tests/lib/autonomy-gate.test.mjs. Removed: the round test files (retterunde, retterunde-prose-pins, trial-mechanical-fixes, trial2-fixes, trial2-minor, trial2-minor-prose-pins, fix-round-4-5a-prose, fix-round-4-5b2-b-prose, timing-under-load), the 8 R8 e2e scripts, and seven round test files on the R-a removal list that pinned them (fix-round-4-5b-prose, fix-round-4-5b-part-2/3/4-scripts, fix-round-4-5b-part-4-prose, fix-round-4-5b2-b/c-scripts). The diff-mode e2e pin (test 24) and the red-script round-wrapper cases go too. tests/synthetic/plan-run-C.md becomes tests/fixtures/plan-run-c.md, regenerated from buildPlan({ version: '1.9' }). The red judge, reporter and judge test headers now name the shared red script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
7.1 KiB
JavaScript
199 lines
7.1 KiB
JavaScript
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
|
|
import { ON_FAILURE_ACTIONS } from '../../lib/parsers/plan-schema.mjs';
|
|
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
|
|
|
const VALID_PLAN = `---
|
|
plan_version: "1.7"
|
|
---
|
|
|
|
# Plan
|
|
|
|
## Implementation Plan
|
|
|
|
### Step 1: Add foo
|
|
|
|
- Files: a.ts
|
|
- Manifest:
|
|
\`\`\`yaml
|
|
manifest:
|
|
expected_paths:
|
|
- a.ts
|
|
min_file_count: 1
|
|
commit_message_pattern: "^feat:"
|
|
bash_syntax_check: []
|
|
forbidden_paths: []
|
|
must_contain: []
|
|
\`\`\`
|
|
|
|
### Step 2: Add bar
|
|
|
|
- Files: b.ts
|
|
- Manifest:
|
|
\`\`\`yaml
|
|
manifest:
|
|
expected_paths:
|
|
- b.ts
|
|
min_file_count: 1
|
|
commit_message_pattern: "^feat:"
|
|
bash_syntax_check: []
|
|
forbidden_paths: []
|
|
must_contain: []
|
|
\`\`\`
|
|
`;
|
|
|
|
const FORBIDDEN_PLAN = `---
|
|
plan_version: "1.7"
|
|
---
|
|
|
|
## Fase 1: Drift form
|
|
|
|
content
|
|
`;
|
|
|
|
const STEP_WITHOUT_MANIFEST = `### Step 1: oops
|
|
no manifest
|
|
|
|
### Step 2: ok
|
|
|
|
- Manifest:
|
|
\`\`\`yaml
|
|
manifest:
|
|
expected_paths: [foo]
|
|
min_file_count: 1
|
|
commit_message_pattern: "^x:"
|
|
bash_syntax_check: []
|
|
forbidden_paths: []
|
|
must_contain: []
|
|
\`\`\`
|
|
`;
|
|
|
|
test('validatePlan — strict accepts canonical v1.7 plan', () => {
|
|
const r = validatePlanContent(VALID_PLAN, { strict: true });
|
|
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
|
assert.equal(r.parsed.steps.length, 2);
|
|
assert.equal(r.parsed.planVersion, '1.7');
|
|
});
|
|
|
|
test('validatePlan — forbidden Fase form blocks in strict mode', () => {
|
|
const r = validatePlanContent(FORBIDDEN_PLAN, { strict: true });
|
|
assert.equal(r.valid, false);
|
|
assert.ok(r.errors.find(e => e.code === 'PLAN_FORBIDDEN_HEADING'));
|
|
});
|
|
|
|
test('validatePlan — manifest count mismatch caught', () => {
|
|
const r = validatePlanContent(STEP_WITHOUT_MANIFEST, { strict: true });
|
|
assert.equal(r.valid, false);
|
|
assert.ok(r.errors.find(e => /Step 1/.test(e.message) && /MANIFEST_MISSING/.test(e.code)));
|
|
});
|
|
|
|
test('validatePlan — version warning when missing', () => {
|
|
const noVersion = VALID_PLAN.replace(/plan_version: "1\.7"\n/, '');
|
|
const r = validatePlanContent(noVersion, { strict: true });
|
|
assert.ok(r.warnings.find(w => w.code === 'PLAN_NO_VERSION'));
|
|
});
|
|
|
|
test('validatePlan — older version triggers warning', () => {
|
|
const old = VALID_PLAN.replace('plan_version: "1.7"', 'plan_version: "1.5"');
|
|
const r = validatePlanContent(old, { strict: true });
|
|
assert.ok(r.warnings.find(w => w.code === 'PLAN_VERSION_MISMATCH'));
|
|
});
|
|
|
|
test('validatePlan — prose plan_version line parses, no PLAN_NO_VERSION (defect S26)', () => {
|
|
// A plan shaped like the real template: plan_version lives only in the
|
|
// prose "Generated by" blockquote, not frontmatter. Must still parse.
|
|
const prosePlan = VALID_PLAN
|
|
.replace(/---\nplan_version: "1\.7"\n---\n\n/, '')
|
|
.replace(
|
|
'# Plan',
|
|
'# Plan\n\n> Generated by trekplan v4.1.0 on 2026-05-10 — `plan_version: 1.7`',
|
|
);
|
|
const r = validatePlanContent(prosePlan, { strict: true });
|
|
assert.equal(r.parsed.planVersion, '1.7');
|
|
assert.equal(r.warnings.find(w => w.code === 'PLAN_NO_VERSION'), undefined);
|
|
});
|
|
|
|
const codes = (list) => list.map((e) => e.code);
|
|
|
|
// ---- PLAN_STEP_TESTS_ONLY: a step whose Files are all tests can never be proven red ----
|
|
|
|
// Step 2 changes only tests for behaviour step 1 delivered.
|
|
const TESTS_ONLY_PLAN = buildPlan({
|
|
steps: [{}, { file: 'tests/e2e.sh', test: 'tests/e2e-guard.test.mjs', endToEnd: 'bash tests/e2e.sh' }],
|
|
});
|
|
|
|
test('a step whose Files are all tests gives PLAN_STEP_TESTS_ONLY, a warning on that step', () => {
|
|
const r = validatePlanContent(TESTS_ONLY_PLAN);
|
|
const w = r.warnings.filter((x) => x.code === 'PLAN_STEP_TESTS_ONLY');
|
|
assert.equal(w.length, 1, codes(r.warnings).join(','));
|
|
assert.match(w[0].message, /Step 2/);
|
|
assert.ok(!codes(r.errors).includes('PLAN_STEP_TESTS_ONLY'), 'a warning, never an error');
|
|
assert.equal(r.valid, true, codes(r.errors).join(','));
|
|
});
|
|
|
|
test('a step with code and its test gives no PLAN_STEP_TESTS_ONLY', () => {
|
|
const r = validatePlanContent(buildPlan());
|
|
assert.ok(!codes(r.warnings).includes('PLAN_STEP_TESTS_ONLY'), codes(r.warnings).join(','));
|
|
});
|
|
|
|
// ---- K10 `Regression:` and K11 `On failure:` -----------------------------------
|
|
|
|
// Step 1's Verify line in the builder's plan; the new field goes right under it.
|
|
const STEP1_VERIFY = '- **Verify:** `node --test tests/mod1.test.mjs` → expected: pass';
|
|
const withRegression = (line, plan = buildPlan()) => plan.replace(STEP1_VERIFY, `${STEP1_VERIFY}\n${line}`);
|
|
const withOnFailure = (text, plan = buildPlan()) =>
|
|
plan.replace(/^- \*\*On failure:\*\* .*$/m, `- **On failure:** ${text}`);
|
|
|
|
test('K10: `Regression: npm test | tee x` is refused — one command, unpiped', () => {
|
|
const r = validatePlanContent(withRegression('- **Regression:** `npm test | tee x` → expected: exit 0'));
|
|
assert.equal(r.valid, false);
|
|
const e = r.errors.find((x) => x.code === 'PLAN_STEP_REGRESSION_NOT_RUNNABLE');
|
|
assert.ok(e, codes(r.errors).join(','));
|
|
assert.match(e.message, /Step 1/);
|
|
assert.match(e.message, /shell operator/);
|
|
});
|
|
|
|
test('K10: `Regression: npm test` is accepted', () => {
|
|
const r = validatePlanContent(withRegression('- **Regression:** `npm test` → expected: exit 0'));
|
|
assert.deepEqual(codes(r.errors), []);
|
|
assert.ok(!codes(r.warnings).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'));
|
|
});
|
|
|
|
test('K10: without the field the plan validates exactly as before', () => {
|
|
const r = validatePlanContent(buildPlan());
|
|
assert.equal(r.valid, true, codes(r.errors).join(','));
|
|
assert.deepEqual(codes(r.warnings), []);
|
|
});
|
|
|
|
test('K10: `npm test && bash tests/e2e.sh`, a bare word, or no backticks is refused', () => {
|
|
for (const line of [
|
|
'- **Regression:** `npm test && bash tests/e2e.sh`',
|
|
'- **Regression:** `./run-all-tests`',
|
|
'- **Regression:** npm test',
|
|
'- **Regression:**',
|
|
]) {
|
|
const r = validatePlanContent(withRegression(line));
|
|
assert.ok(codes(r.errors).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'), `${line}: ${codes(r.errors).join(',')}`);
|
|
}
|
|
});
|
|
|
|
test('K10: --soft reports an unrunnable Regression as a warning, not an error', () => {
|
|
const r = validatePlanContent(withRegression('- **Regression:** `npm test | tee x`'), { strict: false });
|
|
assert.equal(r.valid, true, codes(r.errors).join(','));
|
|
assert.ok(codes(r.warnings).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'));
|
|
});
|
|
|
|
test('K11: `On failure: try again` warns, and the plan stays valid', () => {
|
|
const r = validatePlanContent(withOnFailure('try again'));
|
|
assert.equal(r.valid, true, codes(r.errors).join(','));
|
|
const w = r.warnings.find((x) => x.code === 'PLAN_STEP_ON_FAILURE_UNKNOWN');
|
|
assert.ok(w, codes(r.warnings).join(','));
|
|
assert.match(w.message, /Step 1/);
|
|
for (const word of ON_FAILURE_ACTIONS) assert.match(w.hint, new RegExp(word));
|
|
});
|
|
|
|
test('K11: `On failure: retry once, then escalate` gives no warning', () => {
|
|
const r = validatePlanContent(withOnFailure('retry once, then escalate'));
|
|
assert.ok(!codes(r.warnings).includes('PLAN_STEP_ON_FAILURE_UNKNOWN'), codes(r.warnings).join(','));
|
|
});
|