fix: the findings of the release review of 6.3.0

The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each
fixed behind a test that failed first, or, for the two missing tests, a test
shown to fail on a stub of the branch it covers.

- Review steps appended by /trekexecute Phase 7.2 are held to the step rules
  on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a
  template review step validated and was executed).
- The guard refuses where no one can be asked also when its rules will not
  load; the wave merge path names that refusal instead of MERGE_REFUSED.
  Tests for the classifier-error refusal and post-approval-log's own check.
- release-check: the version files may change only in their version after
  the review (JSON compared without it), README diff lines are read inside
  hunks only, an unreadable diff is exit 2, and npm test runs without
  NODE_TEST_CONTEXT.
- What counts as a step of only tests: a note in parentheses is not a path,
  and a data file under specs/ is not a test. End-to-end: rides on the last
  step that builds behaviour (validator hint, trekplan, template).
- A removed line is cited as <rev>:<path>:<line> (findings-schema hint,
  trekreview re-ask).
- skill-lint counts a trailing newline as no line; regression-check takes
  commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read
  exemptions.
- /security-review, older than v6.2.0: the criteria runner refuses git
  --output, which writes a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-10 22:12:12 +02:00
commit bef9c5b3ac
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
18 changed files with 450 additions and 43 deletions

View file

@ -288,10 +288,12 @@ export function onFailureAction(text) {
* The paths a field names. Each entry (comma- or line-separated, a sub-bullet's
* `-` dropped) gives its backticked spans, or else its first word: a plain
* path beside a backticked one is still a path (independent review, order 10).
* A parenthesised note is not an entry: "`a.test.mjs` (new: covers x, y)"
* names one path, not "y)" too (release review of 6.3.0, 2026-10-10).
*/
function fieldPaths(text) {
const out = [];
for (const raw of String(text ?? '').split(/[,\n]/)) {
for (const raw of String(text ?? '').replace(/\([^()\n]*\)/g, ' ').split(/[,\n]/)) {
const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue;
const spans = [...entry.matchAll(/`([^`\n]+)`/g)].map((m) => m[1].trim());
@ -303,7 +305,11 @@ function fieldPaths(text) {
// A document is never the test here: `docs/specs/api.md` sits under a `specs/`
// folder, which lib/util/test-path.mjs reads as tests for the floor guard.
// Nor is a data file under a `spec/` or `specs/` folder (`api/specs/openapi.yaml`
// is an API spec); under tests/ it is a fixture, and a fixture is a test file.
const DOC_RE = /\.(md|markdown|mdx|txt|rst|adoc)$/i;
const DATA_RE = /\.(ya?ml|json|toml|xml|csv)$/i;
const UNDER_TESTS_RE = /(^|\/)(tests?|__tests__)\//;
/**
* Whether every file in a step's `Files:` is a test: the step's own `Test
@ -324,7 +330,8 @@ export function stepTestsOnly(body) {
const testFirst = f.has('Test first')
? [...f.get('Test first').text.matchAll(/\bFile:\**[ \t]*`([^`\n]+)`/gi)].map((m) => m[1].trim())
: [];
const isTest = (p) => testFirst.includes(p) || (isTestPath(p) && !DOC_RE.test(p));
const isTest = (p) => testFirst.includes(p) ||
(isTestPath(p) && !DOC_RE.test(p) && !(DATA_RE.test(p) && !UNDER_TESTS_RE.test(p)));
return { testsOnly: files.length > 0 && files.every(isTest), files };
}

View file

@ -162,7 +162,9 @@ function checkProseCitations(text, where, repoRoot, errors, seen) {
errors.push(issue(
'FINDING_PROSE_LINE_OUT_OF_RANGE',
`${where} cites ${m[0]}, but ${m[1]} has ${n} lines`,
'Cite the line number in the file itself (Read it), not the line in the diff or patch, or drop the line number.',
'Cite the line number in the file itself (Read it), not the line in the diff or patch. A line that is no ' +
'longer in the file is cited with its revision, `<rev>:<path>:<line>` (`v6.2.0:hooks/x.mjs:120`), which ' +
'is not judged against today\'s file.',
));
}
}

View file

@ -145,7 +145,8 @@ export function lintSkill(skillDir, opts = {}) {
if (/lint-exempt/i.test(text)) errors.push(err('SKILL_SELF_EXEMPTION', 'a skill may not exempt itself; exemptions live in EXEMPTIONS in lib/skills/skill-lint.mjs'));
// Size
const lineCount = text.split('\n').length;
// A file that ends in a newline has no line after it (findings-schema counts the same way).
const lineCount = text.endsWith('\n') ? text.split('\n').length - 1 : text.split('\n').length;
if (lineCount >= MAX_LINES) errors.push(err('SKILL_TOO_LONG', `SKILL.md has ${lineCount} lines (must stay under ${MAX_LINES}); move detail to a linked file`));
// Body: headings, enforcement, references, shell injection

View file

@ -164,13 +164,19 @@ function checkRecovery(sections, opts) {
`Open with one of ${ON_FAILURE_ACTIONS.join(' | ')}, then the specifics (\`retry once, then escalate\`).`));
}
if (stepTestsOnly(s.body).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')
? ' `End-to-end:` rides on the last step that builds behaviour, with its script in that step\'s `Files:`, ' +
'not on a step of its own.'
: '';
warnings.push(issue('PLAN_STEP_TESTS_ONLY',
`Step ${s.n}: every file in \`Files:\` is a test; a step of only tests can never be proven red`,
'The test for a behaviour belongs in the step that builds it: at this step\'s start point the behaviour ' +
'already exists (an earlier step built it, or it was there before the plan), so its test passes there and ' +
'red proof rejects it (`not-red-at-base`). Move the test into the step that builds the behaviour; a test ' +
'meant to pass from the start (a golden or characterization test) is the `Regression:` of the step that ' +
'changes the code, not a step of its own.'));
'changes the code, not a step of its own.' + e2e));
}
}
return { errors, warnings };
@ -286,6 +292,27 @@ export function validatePlanContent(rawText, opts = {}) {
else warnings.push(...recovery.errors);
warnings.push(...recovery.warnings);
// 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
// /trekreview" validated and was executed (release review of 6.3.0,
// 2026-10-10). From 1.8 the rules below already cover every step.
const stepsFrom = Number.isInteger(opts.stepsFrom) && opts.stepsFrom > 0 ? opts.stepsFrom : null;
if (stepsFrom !== null && !versionAtLeast(planVersion, '1.8')) {
const appended = sections.filter((s) => Number(s.n) >= stepsFrom);
const stepIssues = [
...checkSteps(appended).filter((i) => i.code !== 'PLAN_NO_END_TO_END'),
...checkRunnable('', appended, { cwd: opts.cwd }),
];
for (const s of appended) {
if (!parseStepFields(s.body).has('Verify')) {
stepIssues.push(issue('PLAN_STEP_VERIFY_NOT_RUNNABLE', `Step ${s.n}: no \`Verify:\` field`,
'An appended step needs one test command: proof of red and the done line run it.'));
}
}
errors.push(...stepIssues);
}
const ceremony = extractPlanCeremony(text);
if (versionAtLeast(planVersion, '1.8')) {
const v18 = [...checkSteps(sections), ...checkRunnable(text, sections, { cwd: opts.cwd })];
@ -352,12 +379,14 @@ function isMain() {
if (isMain()) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: plan-validator.mjs [--strict|--soft] <plan.md>\n');
const at = args.indexOf('--steps-from');
const stepsFrom = at >= 0 ? Number(args[at + 1]) : null;
const filePath = args.find((a, i) => !a.startsWith('--') && !(at >= 0 && i === at + 1));
if (!filePath || (at >= 0 && !(Number.isInteger(stepsFrom) && stepsFrom > 0))) {
process.stderr.write('Usage: plan-validator.mjs [--strict|--soft] [--json] [--steps-from <N>] <plan.md>\n');
process.exit(2);
}
const r = validatePlan(filePath, { strict });
const r = validatePlan(filePath, { strict, stepsFrom });
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({
valid: r.valid,

View file

@ -315,10 +315,13 @@ const RUNNERS = {
bash: (t) => (isTestScript(t[1]) ? permit() : deny('bash, but not a script under tests/')),
// The system bash by its path (macOS: 3.2), so a check can pin the shell.
'/bin/bash': (t) => (isTestScript(t[1]) ? permit() : deny('/bin/bash, but not a script under tests/')),
git: (t) =>
READONLY_GIT.has(t[1])
? permit()
: deny(`git, but not a read-only subcommand first (${[...READONLY_GIT].join('|')})`),
git: (t) => {
if (!READONLY_GIT.has(t[1])) return deny(`git, but not a read-only subcommand first (${[...READONLY_GIT].join('|')})`);
// `--output` makes diff/log/show write their output to a file (release
// review of 6.3.0, 2026-10-10: a plan's check could overwrite any file).
if (t.some((w) => w === '--output' || w.startsWith('--output='))) return deny('git with --output writes a file, so it is not a read-only check');
return permit();
},
};
/**

View file

@ -24,23 +24,23 @@
// progress.json uses (C15). Nothing else is translated.
//
// The command runs the way the runner runs it (`/bin/sh -c`), but without
// NODE_TEST_CONTEXT in its environment (red-proof.mjs `commandEnv`): measured
// NODE_TEST_CONTEXT in its environment (lib/util/command-env.mjs): measured
// 2026-10-08, a red `node --test` regression read as passed when this CLI was
// itself started under `node --test`, because the nested run reported to the
// parent and exited 0.
function exec(command, { cwd, timeoutMs }) {
const r = spawnSync('/bin/sh', ['-c', command], { cwd, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
import { readFileSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { firstCommand, runCriteria } from './criteria-runner.mjs';
import { commandEnv } from './red-proof.mjs';
import { commandEnv } from '../util/command-env.mjs';
function exec(command, { cwd, timeoutMs }) {
const r = spawnSync('/bin/sh', ['-c', command], { cwd, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
export const REGRESSION_FIELD = 'Regression';
const EXIT = Object.freeze({ passed: 0, absent: 0, failed: 1, blocked: 1, unrunnable: 1, error: 2 });