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>
166 lines
9.8 KiB
JavaScript
166 lines
9.8 KiB
JavaScript
// tests/lib/review-630.test.mjs
|
|
// The findings of the release review of 6.3.0 (2026-10-10), each held by a
|
|
// test that failed on f669b87. The guard's and the release check's own tests
|
|
// live next to their files (tests/commands/trekexecute-coupling.test.mjs,
|
|
// tests/hooks/guards-review-regressions.test.mjs, tests/scripts/release-check.test.mjs).
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
|
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
|
|
import { stepTestsOnly } from '../../lib/parsers/plan-schema.mjs';
|
|
import { validateFindings } from '../../lib/review/findings-schema.mjs';
|
|
import { lintSkill } from '../../lib/skills/skill-lint.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
|
const VALIDATOR = join(ROOT, 'lib', 'validators', 'plan-validator.mjs');
|
|
|
|
// The review template's own Step R1, appended the Phase 7.2 way.
|
|
function withTemplateStep(plan, n) {
|
|
const r1 = /### Step R1:[^\n]*\n[\s\S]*?(?=\n> )/.exec(read('templates/trekreview-template.md'));
|
|
assert.ok(r1, 'templates/trekreview-template.md carries a ### Step R1');
|
|
// Phase 7.2 step 2 also adds the manifest the legacy rule synthesizes (expected_paths ← Files).
|
|
const appended = r1[0].replace(/^### Step R1:/, `### Step ${n}:`) +
|
|
'- **Checkpoint:** `git commit -m "fix(review): x [01234567]"`\n' +
|
|
'- **Manifest:**\n ```yaml\n manifest:\n expected_paths:\n - lib/foo.mjs\n min_file_count: 1\n' +
|
|
' commit_message_pattern: "^fix\\\\(review\\\\):"\n bash_syntax_check: []\n forbidden_paths: []\n must_contain: []\n ```\n';
|
|
return plan.replace('\n## Test Strategy', `\n${appended}\n## Test Strategy`);
|
|
}
|
|
|
|
// ---- BLOCKER 1b25ba56: appended review steps hold the 1.8 step rules on any plan
|
|
|
|
test('review 630 / 1b25ba56: on a 1.7 plan, an appended review step that cannot run is refused (--steps-from)', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'r630-'));
|
|
try {
|
|
const plan = withTemplateStep(buildPlan({ version: '1.7', design: false }), 3);
|
|
const p = join(dir, 'plan.md');
|
|
writeFileSync(p, plan);
|
|
const plain = spawnSync(process.execPath, [VALIDATOR, '--strict', '--json', p], { encoding: 'utf8' });
|
|
assert.equal(plain.status, 0, 'the control: a 1.7 plan is not held to the 1.8 step rules');
|
|
const r = spawnSync(process.execPath, [VALIDATOR, '--strict', '--json', '--steps-from', '3', p], { encoding: 'utf8' });
|
|
assert.equal(r.status, 1, `the appended step is held to them: ${r.stdout}`);
|
|
const codes = JSON.parse(r.stdout).errors.map((e) => e.code);
|
|
assert.ok(codes.includes('PLAN_STEP_VERIFY_NOT_RUNNABLE') || codes.includes('PLAN_STEP_NO_TEST_FIRST'), codes.join(','));
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('review 630 / 1b25ba56: --steps-from leaves the earlier steps of a 1.7 plan alone', () => {
|
|
const plan = buildPlan({ version: '1.7', design: false, steps: [{ testFirst: false }, { endToEnd: 'bash tests/e2e.sh' }] });
|
|
assert.deepEqual(validatePlanContent(plan, { strict: true, stepsFrom: 3 }).errors, [],
|
|
'step 1 has no Test first, and it is not one of the appended steps');
|
|
});
|
|
|
|
test('review 630 / 1b25ba56: Phase 7.2 validates the appended steps with --steps-from', () => {
|
|
const cmd = read('commands/trekexecute.md');
|
|
assert.match(cmd, /plan-validator\.mjs --strict --json --steps-from \{total \+ 1\} "\{plan_path\}"/);
|
|
});
|
|
|
|
// ---- 57a5e67c: the wave path names the guard's refusal ------------------------
|
|
|
|
test('review 630 / 57a5e67c: the wave merge path says what to do when the guard refused because no one can be asked', () => {
|
|
const cmd = read('commands/trekexecute.md');
|
|
const at = cmd.indexOf('**Merge session branches back (SEQUENTIAL, one at a time):**');
|
|
const before = cmd.slice(cmd.lastIndexOf('- **`all_completed: true`:**', at), at);
|
|
assert.match(before, /no one to ask/, 'the guard-refused case is named on the wave path');
|
|
assert.match(before, /by hand/, 'and the way on: the operator runs the merge by hand');
|
|
assert.match(before, /wave\.json/, 'wave.json is kept');
|
|
});
|
|
|
|
// ---- 2cf29d6f / 727924c0: what counts as a step of only tests ------------------
|
|
|
|
test('review 630: a comma inside a Files: note is not a path', () => {
|
|
const body = '- **Files:** `tests/a.test.mjs` (new: covers x, y)\n- **Changes:** add the test\n';
|
|
assert.deepEqual(stepTestsOnly(body), { testsOnly: true, files: ['tests/a.test.mjs'] });
|
|
});
|
|
|
|
test('review 630: a data file under specs/ is not a test', () => {
|
|
const body = '- **Files:** `api/specs/openapi.yaml`\n- **Changes:** add the endpoint\n';
|
|
assert.equal(stepTestsOnly(body).testsOnly, false);
|
|
const fixture = '- **Files:** `tests/fixtures/users.json`\n- **Changes:** add the fixture\n';
|
|
assert.equal(stepTestsOnly(fixture).testsOnly, true, 'the control: a fixture under tests/ is a test file');
|
|
});
|
|
|
|
test('review 630: a tests-only End-to-end step is told where End-to-end belongs', () => {
|
|
const plan = buildPlan({ steps: [{}, { file: 'tests/e2e.sh', test: 'tests/e2e.sh', endToEnd: 'bash tests/e2e.sh' }] });
|
|
const w = validatePlanContent(plan, { strict: true }).warnings.find((x) => x.code === 'PLAN_STEP_TESTS_ONLY');
|
|
assert.ok(w, 'the warning fires: red proof would reject this step');
|
|
assert.match(`${w.message} ${w.hint ?? ''}`, /`End-to-end:` (rides|belongs) on the last step that builds/);
|
|
assert.match(read('templates/plan-template.md'), /`End-to-end:` (rides|belongs) on the last step that builds/);
|
|
});
|
|
|
|
// ---- c4623362: a removed line can still be cited --------------------------------
|
|
|
|
test('review 630 / c4623362: the out-of-range hint says how to cite a line that no longer exists', () => {
|
|
const r = validateFindings({ reviewer: 'r', findings: [{
|
|
id: 'a'.repeat(40), severity: 'MAJOR', rule_key: 'MISSING_TEST', file: 'package.json', line: 1,
|
|
brief_ref: 'x', title: 't', recommended_action: 'a',
|
|
detail: 'the check formerly at package.json:9999 was removed',
|
|
}] }, { repoRoot: ROOT });
|
|
const e = r.errors.find((x) => x.code === 'FINDING_PROSE_LINE_OUT_OF_RANGE');
|
|
assert.ok(e, 'the control: the plain citation is still checked');
|
|
assert.match(e.hint, /<rev>:<path>:<line>/);
|
|
const ok = validateFindings({ reviewer: 'r', findings: [{
|
|
id: 'a'.repeat(40), severity: 'MAJOR', rule_key: 'MISSING_TEST', file: 'package.json', line: 1,
|
|
brief_ref: 'x', title: 't', recommended_action: 'a',
|
|
detail: 'the check formerly at v6.2.0:package.json:9999 was removed',
|
|
}] }, { repoRoot: ROOT });
|
|
assert.deepEqual(ok.errors, [], 'a revision-qualified citation is not judged against today\'s file');
|
|
assert.match(read('commands/trekreview.md'), /<rev>:<path>:<line>/, 'the re-ask names the form');
|
|
});
|
|
|
|
// ---- 2261016d: a 499-line SKILL.md is under the 500-line limit -------------------
|
|
|
|
test('review 630 / 2261016d: a SKILL.md of 499 lines ending in a newline passes the size rule', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'r630-skill-'));
|
|
try {
|
|
const skill = join(dir, 'test-first');
|
|
cpSync(join(ROOT, 'skills', 'test-first'), skill, { recursive: true });
|
|
const text = readFileSync(join(skill, 'SKILL.md'), 'utf8').replace(/\n+$/, '\n');
|
|
const lines = text.split('\n').length - 1;
|
|
assert.ok(lines < 499, `the fixture pads the real skill (${lines} lines)`);
|
|
const padded = text.replace('\n## Credits', `${'\n'.repeat(499 - lines)}\n## Credits`);
|
|
assert.equal(padded.split('\n').length - 1, 499);
|
|
writeFileSync(join(skill, 'SKILL.md'), padded);
|
|
const codes = lintSkill(skill).errors.map((e) => e.code);
|
|
assert.ok(!codes.includes('SKILL_TOO_LONG'), codes.join(','));
|
|
writeFileSync(join(skill, 'SKILL.md'), padded + '\n');
|
|
assert.ok(lintSkill(skill).errors.map((e) => e.code).includes('SKILL_TOO_LONG'), 'the control: 500 lines fail');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// ---- cb34c32e: regression-check takes commandEnv from the shared util ------------
|
|
|
|
test('review 630 / cb34c32e: regression-check imports commandEnv from lib/util/command-env.mjs', () => {
|
|
const src = read('lib/verification/regression-check.mjs');
|
|
assert.match(src, /import \{ commandEnv \} from '\.\.\/util\/command-env\.mjs';/);
|
|
assert.doesNotMatch(src, /from '\.\/red-proof\.mjs'/);
|
|
assert.ok(src.indexOf('function exec(') > src.lastIndexOf('\nimport '), 'exec is defined after the imports');
|
|
});
|
|
|
|
// ---- CLAUDE.md names the mode-read exemptions as they are -----------------------
|
|
|
|
test('review 630: CLAUDE.md counts the mode-read exemptions the launch test holds', () => {
|
|
const src = read('tests/lib/no-claude-code-launch.test.mjs');
|
|
const n = (src.match(/\['mode-read', \d+,/g) || []).length;
|
|
assert.equal(n, 4, 'the launch test holds four mode-read entries');
|
|
assert.match(read('CLAUDE.md'), /four files that only read\s+(?:> )?the session's permission mode/);
|
|
});
|
|
|
|
// ---- /security-review, older than v6.2.0: a read-only git that writes a file ----
|
|
|
|
test('review 630 / security: `git diff --output=<path>` is not a read-only check', async () => {
|
|
const { allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
|
|
assert.equal(allowedCommand('git diff --stat').allowed, true, 'the control: a read-only git check runs');
|
|
assert.equal(allowedCommand('git log --output-indicator-new=+ -1').allowed, true, 'an option that only starts with --output is not it');
|
|
for (const cmd of ['git diff --output=../../.zshrc', 'git diff --output ../x', 'git log -p --output=x', 'git show HEAD --output=x']) {
|
|
const r = allowedCommand(cmd);
|
|
assert.equal(r.allowed, false, `${cmd} writes a file`);
|
|
assert.match(r.reason, /--output/);
|
|
}
|
|
});
|