feat(review): review rounds carry findings until resolved, one cap of three
This commit is contained in:
parent
1464605e35
commit
635003e8aa
13 changed files with 2021 additions and 100 deletions
4
tests/fixtures/jsonl-schemas.md
vendored
4
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -22,10 +22,10 @@
|
|||
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1141-1165 | profile, phase_models, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:829-862 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1307-1335 | profile, phase_models, parallel_agents, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2990-3012 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2992-3014 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
|
||||
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
|
||||
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:654-704 | profile, phase_models, profile_source | none |
|
||||
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:708-758 | profile, phase_models, profile_source | none |
|
||||
| trekcontinue-stats | ts, project, next_session_label, status | lib/stats/stats-append.mjs (trekcontinue.md Phase 5) | trekcontinue.md:304-308 | profile, profile_source | none |
|
||||
| token-usage | ts, session_id, scope, model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read, cost_usd, is_estimate, price_table_version | lib/stats/token-usage.mjs `captureTokenUsage` (via hooks/scripts/otel-export.mjs Stop hook, opt-in `VOYAGE_TOKEN_METER`) | token-usage.mjs:buildRecord | n/a (SKAL-2 schema) | session_id (stripped at export) |
|
||||
|
||||
|
|
|
|||
|
|
@ -56,11 +56,12 @@ test('P11: the review runs by itself after Phase 7, in Cherny\'s order, before P
|
|||
|
||||
test('P11: a BLOCKER becomes new numbered plan steps, in a bounded loop that ends with the operator', () => {
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
assert.match(p, /TREKEXECUTE_MAX_REVIEW_ROUNDS/, 'the cap is named');
|
||||
assert.match(p, /default \*\*3\*\*/);
|
||||
assert.match(p, /`MAX_REVIEW_ROUNDS`/, 'the cap is named');
|
||||
assert.match(p, /lib\/review\/review-rounds\.mjs/, 'the constant\'s module is named');
|
||||
assert.match(p, /\*\*3\*\*/);
|
||||
assert.match(p, /### Step R<n>/, 'the review\'s remediation steps are the source');
|
||||
assert.match(p, /never reworded/);
|
||||
assert.match(p, /at the cap[^\n]*operator/i);
|
||||
assert.match(p, /at the cap with BLOCKERs or MAJORs answered "fixed" still open goes to the operator/i);
|
||||
assert.match(p, /not improvised/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@
|
|||
// the Versioning paragraph says what 1.1 -> 1.2 changed, MIGRATION.md
|
||||
// holds the 1.2 section with its N-1 window, and every other place that
|
||||
// states the current review_version says 1.2.
|
||||
// RND /trekreview names the round input: `review-rounds.mjs prepare` in
|
||||
// Phase 3, the round diff from `{round_base}` and the previous findings
|
||||
// to mark in Phase 5, and the round fields passed to finalize in Phase 7.
|
||||
// CAP one cap of three rounds: /trekreview and /trekexecute name
|
||||
// `review-rounds.mjs` and `MAX_REVIEW_ROUNDS`, and no env name.
|
||||
// The helpers below are copied, never imported from an earlier prose test.
|
||||
// Fix the SOURCE a pin guards, not the pin.
|
||||
|
||||
|
|
@ -114,3 +119,30 @@ test('[V12] every current review_version statement names 1.2', () => {
|
|||
assert.ok(arch.includes('review_version 1.2'), 'docs/architecture.md names review_version 1.2');
|
||||
assert.ok(!arch.includes('review_version 1.1'), 'docs/architecture.md no longer names review_version 1.1');
|
||||
});
|
||||
|
||||
// --- RND / CAP: review rounds (Step 6) ----------------------------------------------
|
||||
|
||||
test('[RND] /trekreview names the round input: prepare, the round diff, the previous findings to mark, never re-raised as new, every occurrence', () => {
|
||||
const cmd = read('commands/trekreview.md');
|
||||
const p3 = section(cmd, '## Phase 3 — Discover scope SHA range');
|
||||
assert.ok(p3.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-rounds.mjs prepare --project "{project_dir}" --since {before_sha} --json'),
|
||||
'Phase 3 runs review-rounds.mjs prepare');
|
||||
const p5 = flat(section(cmd, '## Phase 5 — Launch parallel reviewers'));
|
||||
assert.ok(p5.includes('git diff {round_base}..{after_sha}'), 'Phase 5 gives the reviewers the diff from {round_base}');
|
||||
assert.ok(p5.includes('previous: [{id, resolved}]'), 'Phase 5 asks the reviewers to mark the previous findings as previous: [{id, resolved}]');
|
||||
assert.match(p5, /a previous finding is never re-raised as new/, 'Phase 5 says a previous finding is never re-raised as new');
|
||||
assert.match(p5, /flag every occurrence of a finding class/, 'Phase 5 asks for every occurrence of a finding class');
|
||||
const p7 = flat(section(cmd, '## Phase 7 — Write review.md'));
|
||||
for (const part of ['--round {round}', '--round-base {round_base}', '--previous "{project_dir}/{previous}"', 'review-answers.mjs carry']) {
|
||||
assert.ok(p7.includes(part), `Phase 7 names ${part}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[CAP] /trekreview and /trekexecute both name review-rounds.mjs and MAX_REVIEW_ROUNDS, and neither names TREKEXECUTE_MAX_REVIEW_ROUNDS', () => {
|
||||
for (const rel of ['commands/trekreview.md', 'commands/trekexecute.md']) {
|
||||
const text = read(rel);
|
||||
assert.ok(text.includes('review-rounds.mjs'), `${rel} names review-rounds.mjs`);
|
||||
assert.ok(text.includes('MAX_REVIEW_ROUNDS'), `${rel} names MAX_REVIEW_ROUNDS`);
|
||||
assert.ok(!text.includes('TREKEXECUTE_MAX_REVIEW_ROUNDS'), `${rel} no longer names TREKEXECUTE_MAX_REVIEW_ROUNDS`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
910
tests/lib/review-rounds.test.mjs
Normal file
910
tests/lib/review-rounds.test.mjs
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
// tests/lib/review-rounds.test.mjs
|
||||
// [RND] [CAP] [CRY] fix round 4.5b2 part B, Step 6: /trekreview rounds converge.
|
||||
// RND `lib/review/review-rounds.mjs prepare` derives the round from the
|
||||
// run's archives (`review-round-<run>-<n>.md`, <run> = the first 12 hex
|
||||
// of scope_sha_start), checks the round base, archives the previous
|
||||
// review and clears the previous round's reviewer and engine files;
|
||||
// `carry` keeps a previous finding until this round gives positive
|
||||
// evidence that it is resolved; review-finalize.mjs runs the carry pass
|
||||
// with `--previous`, writes `ran`, `resolved`, `round`, `round_base`,
|
||||
// `finalized` and an R-step for a carried BLOCKER.
|
||||
// CAP one cap of three rounds (`MAX_REVIEW_ROUNDS`).
|
||||
// CRY an accepted answer is carried to the next round only by a link to the
|
||||
// same run's earlier archive, checked by the gate.
|
||||
// Tests run the CLIs (prepare, finalize, review-answers, validator, gate) in
|
||||
// throwaway git repos with no hooks; `carry` is also called directly. Each
|
||||
// tagged test's main assertion is red at the step's start; a case that
|
||||
// already holds there is a Control inside it.
|
||||
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { computeFindingId, computeFindingIds } from '../../lib/parsers/finding-id.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const ROUNDS = join(ROOT, 'lib', 'review', 'review-rounds.mjs');
|
||||
const FINALIZE = join(ROOT, 'lib', 'review', 'review-finalize.mjs');
|
||||
const ANSWERS = join(ROOT, 'lib', 'review', 'review-answers.mjs');
|
||||
const ENGINE = join(ROOT, 'lib', 'review', 'engine-findings.mjs');
|
||||
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||
const VALIDATOR = join(ROOT, 'lib', 'validators', 'review-validator.mjs');
|
||||
const PROJECT_REL = '.claude/projects/2026-11-02-r';
|
||||
const TRAILER = 'Co-Authored-By: Claude Test <noreply@example.invalid>';
|
||||
const CCR = 'code-correctness-reviewer';
|
||||
const BCR = 'brief-conformance-reviewer';
|
||||
|
||||
// Loaded so that each new assertion fails on its own while the module is absent.
|
||||
let rounds = {};
|
||||
try {
|
||||
rounds = await import(pathToFileURL(ROUNDS).href);
|
||||
} catch (e) {
|
||||
if (existsSync(ROUNDS)) throw e;
|
||||
}
|
||||
|
||||
const made = [];
|
||||
after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
function g(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function putIn(dir, rel, text) {
|
||||
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
||||
writeFileSync(join(dir, rel), text);
|
||||
}
|
||||
|
||||
const parse = (r) => { try { return JSON.parse(r.stdout); } catch { return {}; } };
|
||||
const run = (args, opts = {}) => spawnSync(process.execPath, args, { encoding: 'utf8', env: childEnv(), ...opts });
|
||||
// Ten lines `export const <tag><n> = <n>;`; `changes` maps a line number to new text.
|
||||
const ten = (tag, changes = {}) => Array.from({ length: 10 }, (_, i) => (i + 1 in changes ? changes[i + 1] : `export const ${tag}${i + 1} = ${i + 1};`)).join('\n') + '\n';
|
||||
const jsonOf = (text) => JSON.parse(text.slice(text.lastIndexOf('```json\n') + 8, text.lastIndexOf('```')));
|
||||
const fmOf = (text) => text.slice(0, text.indexOf('\n---\n', 4) + 5);
|
||||
|
||||
/**
|
||||
* A finding as a reviewer writes it. `f` holds severity, file, line and the
|
||||
* optional rule_key, engine, title, detail, id and carried_from.
|
||||
*/
|
||||
function finding(f, i) {
|
||||
return {
|
||||
...(f.id ? { id: f.id } : {}),
|
||||
severity: f.severity, rule_key: f.rule_key ?? 'MISSING_TEST', ...(f.engine ? { engine: f.engine } : {}),
|
||||
file: f.file, line: f.line, brief_ref: 'SC1', title: f.title ?? `title ${f.file} ${f.line} ${i + 1}`,
|
||||
detail: f.detail ?? `detail ${f.file} ${f.line} ${i + 1}`, recommended_action: f.action ?? 'Add a test.',
|
||||
...(f.carried_from ? { carried_from: f.carried_from } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A review_version 1.2 review of `start..end`, ids filled as fill-finding-ids
|
||||
* fills them (computed over the array). One `### Step R<n>` per BLOCKER
|
||||
* unless `steps` names them ([{title, source}]).
|
||||
*/
|
||||
function rndReview({ start, end, findings, verdict, steps, extraSteps = '' }) {
|
||||
const list = findings.map(finding);
|
||||
const ids = computeFindingIds(list);
|
||||
list.forEach((f, i) => { if (!f.id) f.id = ids[i]; });
|
||||
const ordered = list.map(({ id, ...rest }) => ({ id, ...rest }));
|
||||
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
|
||||
for (const f of ordered) counts[f.severity] += 1;
|
||||
const v = verdict ?? (counts.BLOCKER > 0 ? 'BLOCK' : counts.MAJOR > 0 ? 'WARN' : 'ALLOW');
|
||||
const json = JSON.stringify({ verdict: v, counts, allow_blocked_by: [], findings: ordered }, null, 2);
|
||||
const sections = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'].map((sev) => {
|
||||
const of = ordered.filter((f) => f.severity === sev);
|
||||
if (of.length === 0) return '';
|
||||
return `## Findings (${sev})\n\n${of.map((f) => `### ${f.id}\n\n- file: ${f.file}\n- line: ${f.line}\n- rule_key: ${f.rule_key}\n- title: ${f.title}\n`).join('\n')}\n`;
|
||||
}).join('');
|
||||
const stepList = steps ?? ordered.filter((f) => f.severity === 'BLOCKER').map((f) => ({ title: `fix ${f.title}`, source: f.id, file: f.file }));
|
||||
const stepText = stepList.map((s, i) => `### Step R${i + 1}: ${s.title}\n\n- **Files:** \`${s.file ?? 'a.mjs'}\`\n- **Source finding:** \`${s.source}\`\n- **Test first:** a test that fails today\n- **Verify:** re-run /trekreview\n- **On failure:** escalate\n`).join('\n');
|
||||
return {
|
||||
ids: ordered.map((f) => f.id),
|
||||
text: `---
|
||||
type: trekreview
|
||||
review_version: "1.2"
|
||||
task: "t"
|
||||
slug: r
|
||||
project_dir: ${PROJECT_REL}/
|
||||
brief_path: ${PROJECT_REL}/brief.md
|
||||
scope_sha_start: ${start}
|
||||
scope_sha_end: ${end}
|
||||
reviewed_files_count: 2
|
||||
verdict: ${v}
|
||||
findings:
|
||||
${ordered.length ? ordered.map((f) => ` - ${f.id}`).join('\n') : ' []'}
|
||||
---
|
||||
|
||||
# Review: t
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| a.mjs | deep-review | default |
|
||||
|
||||
${sections}## Declined to Judge
|
||||
|
||||
None.
|
||||
|
||||
Trimmed: 0
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
${stepText || 'None.\n'}${extraSteps}
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
${json}
|
||||
\`\`\`
|
||||
`.replace('findings:\n []', 'findings: []'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A throwaway repo: start (a.mjs, b.mjs, ten lines each), c1 (a.mjs and b.mjs
|
||||
* line 3 changed: round 1's work), c2 (a.mjs line 5 changed: round 2's work).
|
||||
* HEAD is c2. The project folder is excluded from git, as a local artifact.
|
||||
*/
|
||||
function rndRepo() {
|
||||
const base = mkdtempSync(join(tmpdir(), 'review-rounds-rnd-'));
|
||||
made.push(base);
|
||||
const repo = join(base, 'repo');
|
||||
mkdirSync(repo, { recursive: true });
|
||||
mkdirSync(join(base, 'hooks-none'));
|
||||
g(repo, 'init', '-q', '-b', 'main');
|
||||
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
|
||||
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
|
||||
appendFileSync(join(repo, '.git', 'info', 'exclude'), '.claude/projects/\n');
|
||||
const commit = (files, msg) => {
|
||||
for (const [rel, text] of Object.entries(files)) putIn(repo, rel, text);
|
||||
g(repo, 'add', '-A');
|
||||
g(repo, 'commit', '-q', '-m', msg);
|
||||
return g(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
const start = commit({ 'a.mjs': ten('a'), 'b.mjs': ten('b') }, 'chore: start');
|
||||
const c1 = commit({ 'a.mjs': ten('a', { 3: 'export const a3 = 33;' }), 'b.mjs': ten('b', { 3: 'export const b3 = 33;' }) }, 'feat: round 1');
|
||||
const c2 = commit({ 'a.mjs': ten('a', { 3: 'export const a3 = 33;', 5: 'export const a5 = 55;' }) }, 'feat: round 2');
|
||||
const project = join(repo, ...PROJECT_REL.split('/'));
|
||||
mkdirSync(project, { recursive: true });
|
||||
const reviewPath = join(project, 'review.md');
|
||||
const fx = {
|
||||
base, repo, start, c1, c2, project, reviewPath, run: start.slice(0, 12), commit,
|
||||
write: (text, p = reviewPath) => writeFileSync(p, text),
|
||||
read: (p = reviewPath) => readFileSync(p, 'utf8'),
|
||||
archive: (n, runId = fx.run) => join(project, `review-round-${runId}-${n}.md`),
|
||||
/** Every file name in the project folder with its bytes. */
|
||||
snapshot: () => Object.fromEntries(readdirSync(project).sort().map((n) => [n, readFileSync(join(project, n), 'utf8')])),
|
||||
prepare: (extra = [], since = start) => run([ROUNDS, 'prepare', '--project', project, '--since', since, '--json', ...extra], { cwd: repo }),
|
||||
finalize: (round, extra = []) => run([FINALIZE, '--review', reviewPath, '--repo', repo, '--project', project,
|
||||
'--round', String(round), ...extra], { cwd: repo }),
|
||||
finalize2: () => fx.finalize(2, ['--round-base', c1, '--previous', fx.archive(1)]),
|
||||
gate: () => run([GATE, '--json', reviewPath, '--repo', repo]),
|
||||
validate: () => run([VALIDATOR, '--strict', reviewPath]),
|
||||
/** A reviewer's whole answer, saved as Phase 5 saves it; `previous` marks previous findings by id. */
|
||||
out: (reviewer, previous = [], findings = []) => writeFileSync(join(project, `.review-out-${reviewer}.md`),
|
||||
`Reviewer prose.\n\n\`\`\`json\n${JSON.stringify({ reviewer, findings, previous }, null, 2)}\n\`\`\`\n`),
|
||||
/** An engine payload, converted by the real engine-findings.mjs CLI. */
|
||||
engine: (engine, findings = []) => {
|
||||
const input = join(base, `engine-${engine}.json`);
|
||||
writeFileSync(input, JSON.stringify({ engine, findings }));
|
||||
const r = run([ENGINE, input]);
|
||||
assert.equal(r.status, 0, `engine-findings: ${r.stdout}${r.stderr}`);
|
||||
writeFileSync(join(project, `.review-engine-payload-${engine}.json`), r.stdout);
|
||||
},
|
||||
};
|
||||
return fx;
|
||||
}
|
||||
|
||||
/** Round 1 written, finalized and archived by prepare: the folder at the start of round 2. */
|
||||
function roundOne(fx, findings, { outs = [CCR, BCR], engines = ['code-review', 'security-review'], finalize = true } = {}) {
|
||||
const r1 = rndReview({ start: fx.start, end: fx.c1, findings });
|
||||
fx.write(r1.text);
|
||||
for (const name of outs) fx.out(name);
|
||||
for (const e of engines) fx.engine(e);
|
||||
if (finalize) {
|
||||
const f = fx.finalize(1);
|
||||
assert.equal(f.status, 0, `round-1 finalize: ${f.stdout}${f.stderr}`);
|
||||
}
|
||||
const p = fx.prepare();
|
||||
assert.equal(p.status, 0, `prepare for round 2: ${p.stdout}${p.stderr}`);
|
||||
return { ...r1, prev: jsonOf(fx.read(fx.archive(1))) };
|
||||
}
|
||||
|
||||
/** Round 2's coordinator output, with fresh reviewer and engine files. */
|
||||
function roundTwo(fx, findings, { outs = {}, engines = ['code-review', 'security-review'], ...opts } = {}) {
|
||||
const r2 = rndReview({ start: fx.start, end: fx.c2, findings, ...opts });
|
||||
fx.write(r2.text);
|
||||
for (const [name, marks] of Object.entries(outs)) fx.out(name, marks);
|
||||
for (const e of engines) fx.engine(e);
|
||||
return r2;
|
||||
}
|
||||
|
||||
const MAJ_A3 = { severity: 'MAJOR', file: 'a.mjs', line: 3, title: 'Parser drops the last token', detail: 'parse() stops one token early' };
|
||||
const BLK_B3 = { severity: 'BLOCKER', file: 'b.mjs', line: 3, rule_key: 'BROKEN_SUCCESS_CRITERION', title: 'Build output is never written', detail: 'write() returns before the flush' };
|
||||
|
||||
// ---- [RND] range and the fail-closed base ----------------------------------------
|
||||
|
||||
test('[RND] from round 2, prepare returns the previous scope_sha_end as round_base', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
const r1 = rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] });
|
||||
fx.write(r1.text);
|
||||
const r = fx.prepare();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
const out = parse(r);
|
||||
assert.equal(out.round, 2, r.stdout);
|
||||
assert.equal(out.round_base, fx.c1, r.stdout);
|
||||
assert.equal(out.previous, `review-round-${fx.run}-1.md`, r.stdout);
|
||||
assert.equal(fx.read(fx.archive(1)), r1.text, 'the previous review is archived byte for byte');
|
||||
});
|
||||
|
||||
test('[RND] prepare exits 2 when the previous scope_sha_end is not an ancestor of HEAD', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
// A side branch from c1, as in [R6] verdict-gate.test.mjs.
|
||||
g(fx.repo, 'checkout', '-q', '-b', 'side', fx.c1);
|
||||
const side = fx.commit({ 'b.mjs': ten('b', { 3: 'export const b3 = 33;', 9: 'export const b9 = 99;' }) }, 'feat: side');
|
||||
g(fx.repo, 'checkout', '-q', 'main');
|
||||
fx.write(rndReview({ start: fx.start, end: side, findings: [MAJ_A3] }).text);
|
||||
const before = fx.snapshot();
|
||||
const r = fx.prepare();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(String(parse(r).reason), /round base is not an ancestor of HEAD — not measured/, r.stdout);
|
||||
assert.deepEqual(fx.snapshot(), before, 'a refused prepare archives nothing');
|
||||
// A short sha is no round base either.
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1.slice(0, 12), findings: [MAJ_A3] }).text);
|
||||
const s = fx.prepare();
|
||||
assert.equal(s.status, 2, s.stdout + s.stderr);
|
||||
// Control: the round-1 end on main is an ancestor of HEAD.
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
|
||||
const c = fx.prepare(['--dry-run']);
|
||||
assert.equal(c.status, 0, `Control: ${c.stdout}${c.stderr}`);
|
||||
});
|
||||
|
||||
// ---- [RND] carry -------------------------------------------------------------------
|
||||
|
||||
const P = (f, i = 0) => ({ id: computeFindingId(f.file, f.line, f.rule_key ?? 'MISSING_TEST'), ...finding(f, i) });
|
||||
|
||||
test('[RND] a previous finding is carried until marked resolved by every reviewer', () => {
|
||||
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
|
||||
const p = P(MAJ_A3);
|
||||
const both = { engines: [], reviewers: [CCR, BCR] };
|
||||
const one = rounds.carry([p], [], { marks: [{ id: p.id, resolved: true, reviewer: CCR }], ran: both, previousRan: both });
|
||||
assert.equal(one.findings.length, 1, JSON.stringify(one));
|
||||
assert.equal(one.findings[0].carried_from, p.id);
|
||||
assert.equal(one.findings[0].severity, 'MAJOR');
|
||||
assert.deepEqual(one.resolved, []);
|
||||
const all = rounds.carry([p], [], {
|
||||
marks: [{ id: p.id, resolved: true, reviewer: CCR }, { id: p.id, resolved: true, reviewer: BCR }], ran: both, previousRan: both,
|
||||
});
|
||||
assert.deepEqual(all.findings, []);
|
||||
assert.deepEqual(all.resolved, [p.id]);
|
||||
});
|
||||
|
||||
test('[RND] a repeated finding is folded and not counted as new', () => {
|
||||
const fx = rndRepo();
|
||||
roundOne(fx, [MAJ_A3]);
|
||||
// The same finding raised again with other words: same file, same rule_key.
|
||||
roundTwo(fx, [{ ...MAJ_A3, title: 'Last token lost by the parser' }], { outs: { [CCR]: [] } });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const payload = jsonOf(fx.read());
|
||||
assert.equal(payload.findings.length, 1, JSON.stringify(payload.findings));
|
||||
assert.equal(payload.findings[0].carried_from, computeFindingId('a.mjs', 3, 'MISSING_TEST'), 'the re-raised finding is the carried one');
|
||||
assert.deepEqual(payload.counts, { BLOCKER: 0, MAJOR: 1, MINOR: 0, SUGGESTION: 0 }, 'one MAJOR, not two');
|
||||
});
|
||||
|
||||
test('[RND] a new MAJOR outside the round diff keeps MAJOR', () => {
|
||||
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
|
||||
const fresh = P({ severity: 'MAJOR', file: 'b.mjs', line: 3, title: 'New thing' });
|
||||
const r = rounds.carry([], [fresh], { marks: [], ran: { engines: [], reviewers: [CCR] }, previousRan: { engines: [], reviewers: [CCR] } });
|
||||
assert.deepEqual(r.findings.map((x) => [x.severity, x.carried_from]), [['MAJOR', undefined]]);
|
||||
// In a finalized round 2: b.mjs:3 changed in round 1 only (outside c1..c2), and it still counts as MAJOR.
|
||||
const fx = rndRepo();
|
||||
roundOne(fx, [MAJ_A3]);
|
||||
roundTwo(fx, [MAJ_A3, { severity: 'MAJOR', file: 'b.mjs', line: 3, title: 'New thing' }], { outs: { [CCR]: [] } });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const fresh2 = jsonOf(fx.read()).findings.find((x) => x.file === 'b.mjs');
|
||||
assert.equal(fresh2.severity, 'MAJOR');
|
||||
assert.equal(fresh2.carried_from, undefined);
|
||||
const gate = fx.gate();
|
||||
assert.equal(gate.status, 2, gate.stdout);
|
||||
assert.ok(parse(gate).unanswered.includes(fresh2.id), `the new MAJOR counts: ${gate.stdout}`);
|
||||
});
|
||||
|
||||
test('[RND] two occurrences of one class stay two findings', () => {
|
||||
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
|
||||
const ran = { engines: [], reviewers: [CCR] };
|
||||
const p1 = P({ severity: 'MAJOR', file: 'a.mjs', line: 3 });
|
||||
const p2 = P({ severity: 'MAJOR', file: 'a.mjs', line: 8 });
|
||||
const both = rounds.carry([p1, p2], [P({ severity: 'MAJOR', file: 'a.mjs', line: 8 }), P({ severity: 'MAJOR', file: 'a.mjs', line: 3 })],
|
||||
{ marks: [], ran, previousRan: ran });
|
||||
assert.deepEqual(both.findings.map((x) => `${x.line}<-${x.carried_from}`).sort(), [`3<-${p1.id}`, `8<-${p2.id}`]);
|
||||
// One previous occurrence, two current ones on the same line: one carried, one new, two ids (rule_key#2).
|
||||
const fx = rndRepo();
|
||||
roundOne(fx, [{ severity: 'MAJOR', file: 'a.mjs', line: 3 }]);
|
||||
roundTwo(fx, [{ severity: 'MAJOR', file: 'a.mjs', line: 3 }, { severity: 'MAJOR', file: 'a.mjs', line: 3, title: 'second' }], { outs: { [CCR]: [] } });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const list = jsonOf(fx.read()).findings;
|
||||
assert.equal(list.length, 2, JSON.stringify(list));
|
||||
assert.equal(list.filter((x) => x.carried_from).length, 1, JSON.stringify(list));
|
||||
assert.deepEqual(list.map((x) => x.id), computeFindingIds(list));
|
||||
assert.notEqual(list[0].id, list[1].id);
|
||||
});
|
||||
|
||||
test('[RND] a review.md from another run starts at round 1', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
fx.write(rndReview({ start: fx.c1, end: fx.c2, findings: [MAJ_A3] }).text);
|
||||
const before = fx.snapshot();
|
||||
const r = fx.prepare();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.deepEqual([parse(r).round, parse(r).round_base, parse(r).previous], [1, null, null], r.stdout);
|
||||
assert.deepEqual(fx.snapshot(), before, 'nothing is archived for another run');
|
||||
});
|
||||
|
||||
// ---- [RND] engine findings ----------------------------------------------------------
|
||||
|
||||
const ENG_MAJ = { severity: 'MAJOR', file: 'b.mjs', line: 3, rule_key: 'CODE_REVIEW_DEFECT', engine: 'code-review', title: 'Off by one in loop' };
|
||||
|
||||
test('[RND] an engine MAJOR is resolved when its engine ran this round and did not raise it again', () => {
|
||||
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
|
||||
const p = P(ENG_MAJ);
|
||||
const prevRan = { engines: ['code-review'], reviewers: [CCR] };
|
||||
const r = rounds.carry([p], [], { marks: [], ran: { engines: ['code-review'], reviewers: [CCR] }, previousRan: prevRan });
|
||||
assert.deepEqual([r.findings.length, r.resolved], [0, [p.id]]);
|
||||
});
|
||||
|
||||
test('[RND] an engine MAJOR is carried when its engine did not run, or raised it again, whatever a reviewer marks', () => {
|
||||
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
|
||||
const p = P(ENG_MAJ);
|
||||
const prevRan = { engines: ['code-review'], reviewers: [CCR] };
|
||||
const marks = [{ id: p.id, resolved: true, reviewer: CCR }];
|
||||
const notRun = rounds.carry([p], [], { marks, ran: { engines: [], reviewers: [CCR] }, previousRan: prevRan });
|
||||
assert.deepEqual(notRun.findings.map((x) => x.carried_from), [p.id]);
|
||||
assert.deepEqual(notRun.resolved, []);
|
||||
const again = rounds.carry([p], [P({ ...ENG_MAJ, line: 4 })], { marks, ran: { engines: ['code-review'], reviewers: [CCR] }, previousRan: prevRan });
|
||||
assert.deepEqual(again.findings.map((x) => x.carried_from), [p.id]);
|
||||
assert.deepEqual(again.resolved, []);
|
||||
});
|
||||
|
||||
test('[RND] a --quick round 2 (no security payload) carries every previous security-review finding', () => {
|
||||
const fx = rndRepo();
|
||||
const sec = { severity: 'BLOCKER', file: 'b.mjs', line: 3, rule_key: 'SECURITY_REVIEW_VULNERABILITY', engine: 'security-review', title: 'Path escapes the root' };
|
||||
const { prev } = roundOne(fx, [sec, ENG_MAJ], { outs: [CCR] });
|
||||
const [secId, majId] = prev.findings.map((x) => x.id);
|
||||
// --quick: only /code-review ran and wrote its payload.
|
||||
roundTwo(fx, [], { outs: { [CCR]: [{ id: secId, resolved: true }, { id: majId, resolved: true }] }, engines: ['code-review'] });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const payload = jsonOf(fx.read());
|
||||
assert.deepEqual(payload.ran, { engines: ['code-review'], reviewers: [CCR] });
|
||||
assert.deepEqual(payload.findings.map((x) => x.carried_from), [secId], 'the security finding is carried');
|
||||
assert.deepEqual(payload.resolved, [majId], 'Control: the code-review MAJOR is resolved by a clean code-review run');
|
||||
});
|
||||
|
||||
test('[RND] an engine that ran clean while a reviewer marks the finding resolved: false carries it', () => {
|
||||
assert.equal(typeof rounds.carry, 'function', 'review-rounds.mjs exports carry');
|
||||
const p = P(ENG_MAJ);
|
||||
const ran = { engines: ['code-review'], reviewers: [CCR] };
|
||||
const r = rounds.carry([p], [], { marks: [{ id: p.id, resolved: false, reviewer: CCR }], ran, previousRan: ran });
|
||||
assert.deepEqual([r.findings.map((x) => x.carried_from), r.resolved], [[p.id], []]);
|
||||
});
|
||||
|
||||
// ---- [RND] reviewer-only findings ---------------------------------------------------
|
||||
|
||||
test('[RND] a reviewer-only finding: one reviewer\'s true while the other round-1 reviewer did not run carries it; both true resolves it', () => {
|
||||
const fx = rndRepo();
|
||||
const { prev } = roundOne(fx, [MAJ_A3]);
|
||||
const [id] = prev.findings.map((x) => x.id);
|
||||
roundTwo(fx, [], { outs: { [CCR]: [{ id, resolved: true }] } });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const one = jsonOf(fx.read());
|
||||
assert.deepEqual(one.findings.map((x) => x.carried_from), [id], 'carried: brief-conformance-reviewer ran in round 1, not in round 2');
|
||||
assert.deepEqual(one.resolved, []);
|
||||
roundTwo(fx, [], { outs: { [CCR]: [{ id, resolved: true }], [BCR]: [{ id, resolved: true }] } });
|
||||
const f2 = fx.finalize2();
|
||||
assert.equal(f2.status, 0, f2.stdout + f2.stderr);
|
||||
const both = jsonOf(fx.read());
|
||||
assert.deepEqual(both.findings, []);
|
||||
assert.deepEqual(both.resolved, [id]);
|
||||
const text = fx.read();
|
||||
assert.match(text, /^## Resolved since round 1$/m, text);
|
||||
assert.ok(text.slice(text.indexOf('## Resolved since round 1')).includes('Parser drops the last token'), text);
|
||||
});
|
||||
|
||||
// ---- [RND] round count ------------------------------------------------------------------
|
||||
|
||||
test('[RND] a round-1 review with no round field, then prepare, returns round 2 and writes review-round-<run>-1.md; a second prepare after a stop returns round 2 again', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
const r1 = rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] });
|
||||
assert.doesNotMatch(fmOf(r1.text), /^round:/m, 'the round-1 review has no round field (finalize failed)');
|
||||
fx.write(r1.text);
|
||||
const a = fx.prepare();
|
||||
assert.equal(a.status, 0, a.stdout + a.stderr);
|
||||
assert.equal(parse(a).round, 2, a.stdout);
|
||||
assert.equal(fx.read(fx.archive(1)), r1.text);
|
||||
// A stop at this point: review.md is still round 1, and the archive equals it.
|
||||
const b = fx.prepare();
|
||||
assert.equal(b.status, 0, b.stdout + b.stderr);
|
||||
assert.deepEqual([parse(b).round, parse(b).previous], [2, `review-round-${fx.run}-1.md`], b.stdout);
|
||||
assert.equal(fx.read(fx.archive(1)), r1.text, 'the archive is kept');
|
||||
assert.ok(!existsSync(fx.archive(2)), 'no second archive');
|
||||
});
|
||||
|
||||
test('[RND] an archive with other bytes is exit 2; a round field that disagrees with the archives is exit 2', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
|
||||
fx.write('another review\n', fx.archive(2));
|
||||
const r = fx.prepare();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(String(parse(r).reason), new RegExp(`archive review-round-${fx.run}-2\\.md differs — not measured`), r.stdout);
|
||||
assert.equal(fx.read(fx.archive(2)), 'another review\n', 'the differing archive is left alone');
|
||||
rmSync(fx.archive(2));
|
||||
// round: 2 with no archive of round 1.
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text.replace('\nfindings:', '\nround: 2\nfindings:'));
|
||||
const m = fx.prepare();
|
||||
assert.equal(m.status, 2, m.stdout + m.stderr);
|
||||
assert.match(String(parse(m).reason), /round count does not match the archives — not measured/, m.stdout);
|
||||
});
|
||||
|
||||
test('[RND] prepare --dry-run returns the round and leaves the folder byte for byte unchanged', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
|
||||
fx.out(CCR);
|
||||
fx.engine('code-review');
|
||||
const before = fx.snapshot();
|
||||
const r = fx.prepare(['--dry-run']);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.deepEqual([parse(r).round, parse(r).round_base], [2, fx.c1], r.stdout);
|
||||
assert.deepEqual(fx.snapshot(), before);
|
||||
});
|
||||
|
||||
test('[RND] a second run in the same folder starts at round 1, and its round 2 archives under its own run', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
|
||||
assert.equal(fx.prepare().status, 0);
|
||||
const first = fx.read(fx.archive(1));
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c2, findings: [MAJ_A3] }).text);
|
||||
// Run 2 starts at c1.
|
||||
const one = fx.prepare([], fx.c1);
|
||||
assert.equal(one.status, 0, one.stdout + one.stderr);
|
||||
assert.equal(parse(one).round, 1, one.stdout);
|
||||
fx.write(rndReview({ start: fx.c1, end: fx.c2, findings: [MAJ_A3] }).text);
|
||||
const two = fx.prepare([], fx.c1);
|
||||
assert.equal(two.status, 0, two.stdout + two.stderr);
|
||||
const run2 = fx.c1.slice(0, 12);
|
||||
assert.deepEqual([parse(two).round, parse(two).previous], [2, `review-round-${run2}-1.md`], two.stdout);
|
||||
assert.ok(existsSync(fx.archive(1, run2)));
|
||||
assert.equal(fx.read(fx.archive(1)), first, 'the first run\'s archive is untouched');
|
||||
assert.deepEqual(readdirSync(fx.project).filter((n) => n.startsWith('review-round-')).sort(),
|
||||
[`review-round-${fx.run}-1.md`, `review-round-${run2}-1.md`].sort());
|
||||
});
|
||||
|
||||
test('[RND] prepare --workflow for a round 2 exits 1', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
// Control: round 1 on --workflow is allowed.
|
||||
const c = fx.prepare(['--workflow']);
|
||||
assert.equal(c.status, 0, `Control: ${c.stdout}${c.stderr}`);
|
||||
assert.equal(parse(c).round, 1);
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
|
||||
const before = fx.snapshot();
|
||||
const r = fx.prepare(['--workflow']);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.match(String(parse(r).reason), /from round 2 \/trekreview runs on the standard path — re-run without --workflow/, r.stdout);
|
||||
assert.deepEqual(fx.snapshot(), before);
|
||||
});
|
||||
|
||||
// ---- [RND] resolution by evidence -----------------------------------------------------
|
||||
|
||||
test('[RND] a previous review without finalized: true resolves nothing', () => {
|
||||
const fx = rndRepo();
|
||||
const { prev } = roundOne(fx, [MAJ_A3, ENG_MAJ], { finalize: false });
|
||||
assert.doesNotMatch(fmOf(fx.read(fx.archive(1))), /^finalized:/m);
|
||||
const marks = prev.findings.map((x) => ({ id: x.id, resolved: true }));
|
||||
roundTwo(fx, [], { outs: { [CCR]: marks, [BCR]: marks } });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const payload = jsonOf(fx.read());
|
||||
assert.deepEqual(payload.findings.map((x) => x.carried_from).sort(), prev.findings.map((x) => x.id).sort());
|
||||
assert.deepEqual(payload.resolved, []);
|
||||
});
|
||||
|
||||
test('[RND] a previous review with ran.reviewers: [] resolves no reviewer finding', () => {
|
||||
const fx = rndRepo();
|
||||
const { prev } = roundOne(fx, [MAJ_A3], { outs: [] });
|
||||
assert.deepEqual(prev.ran?.reviewers, [], 'round 1 was finalized with no reviewer output');
|
||||
const [id] = prev.findings.map((x) => x.id);
|
||||
roundTwo(fx, [], { outs: { [CCR]: [{ id, resolved: true }], [BCR]: [{ id, resolved: true }] } });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const payload = jsonOf(fx.read());
|
||||
assert.deepEqual(payload.findings.map((x) => x.carried_from), [id]);
|
||||
assert.deepEqual(payload.resolved, []);
|
||||
});
|
||||
|
||||
test('[RND] a previous code-review MAJOR is resolved by a code-review payload converted from an empty engine run', () => {
|
||||
const fx = rndRepo();
|
||||
const { prev } = roundOne(fx, [ENG_MAJ], { outs: [CCR] });
|
||||
const [id] = prev.findings.map((x) => x.id);
|
||||
// Control: with no code-review payload this round, the finding is carried.
|
||||
roundTwo(fx, [], { outs: { [CCR]: [] }, engines: [] });
|
||||
const c = fx.finalize2();
|
||||
assert.equal(c.status, 0, c.stdout + c.stderr);
|
||||
assert.deepEqual(jsonOf(fx.read()).findings.map((x) => x.carried_from), [id]);
|
||||
// `node lib/review/engine-findings.mjs` on {"engine":"code-review","findings":[]}.
|
||||
roundTwo(fx, [], { outs: { [CCR]: [] }, engines: ['code-review'] });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const payload = jsonOf(fx.read());
|
||||
assert.deepEqual(payload.ran.engines, ['code-review']);
|
||||
assert.deepEqual(payload.findings, []);
|
||||
assert.deepEqual(payload.resolved, [id]);
|
||||
});
|
||||
|
||||
test('[RND] prepare deletes old .review-engine-payload-*.json and .review-out-*.md files', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
|
||||
fx.out(CCR);
|
||||
fx.out(BCR);
|
||||
fx.engine('code-review');
|
||||
fx.engine('security-review');
|
||||
writeFileSync(join(fx.project, '.review-engine-code-review.json'), '{}');
|
||||
writeFileSync(join(fx.project, '.review-scope.txt'), 'a.mjs\n');
|
||||
const r = fx.prepare();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
const left = readdirSync(fx.project).sort();
|
||||
assert.deepEqual(left.filter((n) => n.startsWith('.review-out-') || n.startsWith('.review-engine-payload-')), [], left.join(', '));
|
||||
// Control: the engine transcription and the scope list stay.
|
||||
assert.ok(left.includes('.review-engine-code-review.json') && left.includes('.review-scope.txt'), left.join(', '));
|
||||
});
|
||||
|
||||
// ---- [RND] ids and R-steps after the carry ----------------------------------------------
|
||||
|
||||
/** Every `Source finding:` id under ## Remediation Steps. */
|
||||
const sources = (text) => [...text.slice(text.indexOf('## Remediation Steps')).matchAll(/Source finding:\**\s*`?([0-9a-f]{40})/g)].map((m) => m[1]);
|
||||
|
||||
test('[RND] a finalized round-2 review with a fold and a carried BLOCKER has every id computed, the BLOCKER\'s R-step, and passes the validator and the gate', () => {
|
||||
const fx = rndRepo();
|
||||
const { prev } = roundOne(fx, [MAJ_A3, BLK_B3]);
|
||||
const blkPrev = prev.findings.find((x) => x.severity === 'BLOCKER').id;
|
||||
// Round 2 re-raises the MAJOR (one line lower: a fold) and not the BLOCKER.
|
||||
roundTwo(fx, [{ ...MAJ_A3, line: 4 }], { outs: { [CCR]: [], [BCR]: [] } });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const text = fx.read();
|
||||
const payload = jsonOf(text);
|
||||
assert.equal(payload.findings.length, 2, JSON.stringify(payload.findings));
|
||||
assert.deepEqual(payload.findings.map((x) => x.id), computeFindingIds(payload.findings), 'every id is the computed one');
|
||||
const blk = payload.findings.find((x) => x.severity === 'BLOCKER');
|
||||
assert.equal(blk.carried_from, blkPrev);
|
||||
assert.equal(payload.verdict, 'BLOCK', 'the verdict is raised by the carried BLOCKER');
|
||||
assert.match(fmOf(text), /^verdict: BLOCK$/m);
|
||||
assert.deepEqual(payload.counts, { BLOCKER: 1, MAJOR: 1, MINOR: 0, SUGGESTION: 0 });
|
||||
assert.match(fmOf(text), /^round: 2$/m);
|
||||
assert.match(fmOf(text), new RegExp(`^round_base: ${fx.c1}$`, 'm'));
|
||||
assert.match(fmOf(text), /^finalized: true$/m);
|
||||
const fm = fmOf(text);
|
||||
for (const x of payload.findings) {
|
||||
assert.ok(fm.includes(` - ${x.id}\n`), `the frontmatter lists ${x.id}`);
|
||||
assert.ok(text.includes(`### ${x.id}\n`), `the body has an entry for ${x.id}`);
|
||||
}
|
||||
assert.ok(sources(text).includes(blk.id), `the carried BLOCKER has its R-step with the recomputed id:\n${text}`);
|
||||
const v = fx.validate();
|
||||
assert.equal(v.status, 0, `review-validator --strict: ${v.stdout}${v.stderr}`);
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.equal(parse(r).reason, 'verdict BLOCK', r.stdout);
|
||||
});
|
||||
|
||||
test('[RND] a BLOCKER re-raised with the coordinator\'s own R-step keeps exactly that one R-step; an orphan R-step makes finalize exit 1', () => {
|
||||
const fx = rndRepo();
|
||||
const { prev } = roundOne(fx, [BLK_B3]);
|
||||
const blkPrev = prev.findings[0].id;
|
||||
const r2 = roundTwo(fx, [{ ...BLK_B3, line: 4, title: 'Build output never reaches disk' }], { outs: { [CCR]: [], [BCR]: [] } });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const text = fx.read();
|
||||
const payload = jsonOf(text);
|
||||
const carried = payload.findings.find((x) => x.carried_from === blkPrev);
|
||||
assert.ok(carried, `the re-raised BLOCKER is the carried one: ${JSON.stringify(payload.findings)}`);
|
||||
assert.equal((text.match(/^### Step R\d+/gm) ?? []).length, 1, text);
|
||||
assert.ok(text.includes('### Step R1: fix Build output never reaches disk'), 'the one R-step is the coordinator\'s');
|
||||
assert.deepEqual(sources(text), [carried.id]);
|
||||
const ids = new Set(payload.findings.map((x) => x.id));
|
||||
assert.deepEqual(sources(text).filter((s) => !ids.has(s)), [], 'no Source finding names an absent id');
|
||||
assert.equal(fx.validate().status, 0);
|
||||
// A planted orphan R-step.
|
||||
const orphan = '\n### Step R9: fix nothing\n\n- **Files:** `a.mjs`\n- **Source finding:** `' + 'e'.repeat(40) + '`\n- **On failure:** escalate\n';
|
||||
roundTwo(fx, [{ ...BLK_B3, line: 4, title: 'Build output never reaches disk' }], { outs: { [CCR]: [], [BCR]: [] }, extraSteps: orphan });
|
||||
const planted = fx.read();
|
||||
assert.equal(r2.ids.length, 1);
|
||||
const o = fx.finalize2();
|
||||
assert.equal(o.status, 1, o.stdout + o.stderr);
|
||||
assert.equal(fx.read(), planted, 'a refused finalize leaves the file as it was');
|
||||
});
|
||||
|
||||
test('[RND] a round-2 review that finalize did not finalize is not measured: the gate exits 2', () => {
|
||||
const fx = rndRepo();
|
||||
roundOne(fx, [BLK_B3, MAJ_A3]);
|
||||
// Round 2 re-raises nothing; the coordinator's one R-step names an id the review does not hold.
|
||||
roundTwo(fx, [], { outs: { [CCR]: [], [BCR]: [] }, steps: [{ title: 'fix an old finding', source: 'f'.repeat(40) }] });
|
||||
const f = fx.finalize2();
|
||||
assert.equal(f.status, 1, f.stdout + f.stderr);
|
||||
assert.doesNotMatch(fmOf(fx.read()), /^finalized: true$/m, 'a refused finalize leaves the file unfinalized');
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(parse(r).reason, 'review-not-measured: round 2+ review not finalized', r.stdout);
|
||||
// An unfinalized round-2 review with no findings and no R-step: exit 2 as well.
|
||||
roundTwo(fx, [], { outs: { [CCR]: [], [BCR]: [] } });
|
||||
const n = fx.gate();
|
||||
assert.equal(n.status, 2, n.stdout + n.stderr);
|
||||
assert.equal(parse(n).reason, 'review-not-measured: round 2+ review not finalized', n.stdout);
|
||||
// Control: the same review finalized is decided on (the carried BLOCKER blocks).
|
||||
const ok = fx.finalize2();
|
||||
assert.equal(ok.status, 0, `Control: ${ok.stdout}${ok.stderr}`);
|
||||
const b = fx.gate();
|
||||
assert.equal(b.status, 1, `Control: ${b.stdout}${b.stderr}`);
|
||||
// Control: an unfinalized round-1 review with only another run's archive beside it is decided on.
|
||||
const other = rndRepo();
|
||||
other.write(rndReview({ start: other.start, end: other.c2, findings: [] }).text);
|
||||
other.write('another run\n', other.archive(1, 'f'.repeat(12)));
|
||||
const one = other.gate();
|
||||
assert.equal(one.status, 0, `Control: ${one.stdout}${one.stderr}`);
|
||||
});
|
||||
|
||||
test('[RND] a carried MAJOR whose line moved under the round\'s fix is never pre-existing: the gate keeps exit 2', () => {
|
||||
const fx = rndRepo();
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
|
||||
fx.out(CCR);
|
||||
fx.out(BCR);
|
||||
const f1 = fx.finalize(1);
|
||||
assert.equal(f1.status, 0, f1.stdout + f1.stderr);
|
||||
// The round's fix puts two lines on top of a.mjs: the old line 3 is now line 5.
|
||||
const c3 = fx.commit({ 'a.mjs': `// header 1\n// header 2\n${readFileSync(join(fx.repo, 'a.mjs'), 'utf8')}` }, 'fix: round 2 work');
|
||||
const p = fx.prepare();
|
||||
assert.equal(p.status, 0, p.stdout + p.stderr);
|
||||
fx.write(rndReview({ start: fx.start, end: c3, findings: [] }).text);
|
||||
fx.out(CCR);
|
||||
fx.out(BCR);
|
||||
const f2 = fx.finalize(2, ['--round-base', fx.c1, '--previous', fx.archive(1)]);
|
||||
assert.equal(f2.status, 0, f2.stdout + f2.stderr);
|
||||
const text = fx.read();
|
||||
const carried = jsonOf(text).findings.find((x) => x.carried_from);
|
||||
assert.ok(carried, `the MAJOR is carried: ${JSON.stringify(jsonOf(text).findings)}`);
|
||||
assert.equal(carried.line, 3, 'a carried finding keeps its line');
|
||||
assert.equal(carried.pre_existing, false, 'finalize never marks a carried finding pre-existing');
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered: 1 MAJOR/, r.stdout);
|
||||
// A hand-set mark does not take it out either.
|
||||
fx.write(editFindings(text, (x) => (x.carried_from ? { ...x, pre_existing: true } : x)));
|
||||
const h = fx.gate();
|
||||
assert.equal(h.status, 2, h.stdout + h.stderr);
|
||||
assert.match(String(parse(h).reason), /^review-unanswered: 1 MAJOR/, h.stdout);
|
||||
});
|
||||
|
||||
// ---- [CAP] the cap ----------------------------------------------------------------------
|
||||
|
||||
test('[CAP] MAX_REVIEW_ROUNDS is 3, and --cap prints it', () => {
|
||||
assert.equal(rounds.MAX_REVIEW_ROUNDS, 3);
|
||||
const r = run([ROUNDS, '--cap']);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout.trim(), '3');
|
||||
});
|
||||
|
||||
test('[CAP] prepare refuses round 4 with exit 1', () => {
|
||||
assert.ok(existsSync(ROUNDS), 'lib/review/review-rounds.mjs must exist');
|
||||
const fx = rndRepo();
|
||||
const r3 = rndReview({ start: fx.start, end: fx.c2, findings: [MAJ_A3] }).text.replace('\nfindings:', '\nround: 3\nfindings:');
|
||||
fx.write(r3);
|
||||
fx.write('round 1\n', fx.archive(1));
|
||||
fx.write('round 2\n', fx.archive(2));
|
||||
const before = fx.snapshot();
|
||||
const r = fx.prepare();
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.match(String(parse(r).reason), /review round cap reached \(3\) — the operator re-plans/, r.stdout);
|
||||
assert.deepEqual(fx.snapshot(), before, 'a refused round writes nothing');
|
||||
// Control: round 3 is allowed.
|
||||
rmSync(fx.archive(2));
|
||||
fx.write(r3.replace('round: 3', 'round: 2'));
|
||||
const c = fx.prepare(['--dry-run']);
|
||||
assert.equal(c.status, 0, `Control: ${c.stdout}${c.stderr}`);
|
||||
assert.equal(parse(c).round, 3);
|
||||
});
|
||||
|
||||
// ---- [CRY] carried answers ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Round 1 finalized with a MAJOR on a.mjs:3, answered `accepted` through
|
||||
* review-answers.mjs record (K2 record committed); prepare; round 2 re-raises
|
||||
* the MAJOR; finalize carries it; review-answers.mjs carry writes the row.
|
||||
*/
|
||||
function cryFlow() {
|
||||
const fx = rndRepo();
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [MAJ_A3] }).text);
|
||||
fx.out(CCR);
|
||||
const f1 = fx.finalize(1);
|
||||
assert.equal(f1.status, 0, f1.stdout + f1.stderr);
|
||||
const oldId = jsonOf(fx.read()).findings[0].id;
|
||||
const rec = run([ANSWERS, 'record', '--review', fx.reviewPath, '--finding', oldId, '--answer', 'accepted', '--repo', fx.repo,
|
||||
'--round', '1', '--store', 'local', '--trailer', TRAILER], { input: 'the parser keeps the old contract on purpose' });
|
||||
assert.equal(rec.status, 0, `record: ${rec.stdout}${rec.stderr}`);
|
||||
const record = parse(rec).record;
|
||||
const p = fx.prepare();
|
||||
assert.equal(p.status, 0, p.stdout + p.stderr);
|
||||
roundTwo(fx, [MAJ_A3], { outs: { [CCR]: [] } });
|
||||
const f2 = fx.finalize2();
|
||||
assert.equal(f2.status, 0, f2.stdout + f2.stderr);
|
||||
const carry = run([ANSWERS, 'carry', '--review', fx.reviewPath, '--previous', fx.archive(1), '--repo', fx.repo]);
|
||||
return { fx, oldId, record, carry, link: `review-round-${fx.run}-1.md#${oldId}` };
|
||||
}
|
||||
|
||||
/** The review with its one `## Answers` row's cells replaced by `fn(cells)`. */
|
||||
function editRow(text, fn) {
|
||||
return text.split('\n').map((l) => {
|
||||
if (!/^\| [0-9a-f]{40} \| /.test(l)) return l;
|
||||
return `| ${fn(l.slice(1, -1).split('|').map((c) => c.trim())).join(' | ')} |`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
/** The review with its JSON block's findings changed by `fn` (ids kept). */
|
||||
function editFindings(text, fn) {
|
||||
const at = text.lastIndexOf('```json\n') + 8;
|
||||
const end = text.lastIndexOf('```');
|
||||
const payload = JSON.parse(text.slice(at, end));
|
||||
payload.findings = payload.findings.map(fn);
|
||||
return `${text.slice(0, at)}${JSON.stringify(payload, null, 2)}\n${text.slice(end)}`;
|
||||
}
|
||||
|
||||
test('[CRY] a round-2 review that carries an accepted MAJOR with the link to its earlier answer exits 0', () => {
|
||||
const { fx, oldId, record, carry, link } = cryFlow();
|
||||
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
|
||||
const text = fx.read();
|
||||
const newId = jsonOf(text).findings[0].id;
|
||||
assert.equal(jsonOf(text).findings[0].carried_from, oldId);
|
||||
assert.ok(text.includes(`| ${newId} | accepted | the parser keeps the old contract on purpose | ${record} | ${link} |`), text);
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
test('[CRY] the same answer without a carried finding and link keeps exit 2', () => {
|
||||
const { fx, carry } = cryFlow();
|
||||
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
|
||||
const good = fx.read();
|
||||
assert.equal(fx.gate().status, 0, 'the carried row opens the gate');
|
||||
// Control: the row without its link.
|
||||
fx.write(editRow(good, (c) => [...c.slice(0, 4), '-']));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 2, r.stdout);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
|
||||
// Control: the link kept, the finding not carried.
|
||||
fx.write(editFindings(good, ({ carried_from: _c, ...rest }) => rest));
|
||||
const n = fx.gate();
|
||||
assert.equal(n.status, 2, n.stdout);
|
||||
assert.match(String(parse(n).reason), /^review-unanswered:/, n.stdout);
|
||||
});
|
||||
|
||||
test('[CRY] a carried row whose finding no longer matches is unanswered', () => {
|
||||
const { fx, carry } = cryFlow();
|
||||
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
|
||||
const good = fx.read();
|
||||
assert.equal(fx.gate().status, 0, 'the carried row opens the gate');
|
||||
// Control: the finding (same id, same carried_from) now says something else under another key.
|
||||
fx.write(editFindings(good, (f) => ({ ...f, rule_key: 'MISSING_ERROR_HANDLING', title: 'Unbounded retry storm', detail: 'renderer loops forever on timeout' })));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 2, r.stdout);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
|
||||
});
|
||||
|
||||
test('[CRY] a row linking to another run\'s archive keeps exit 2, even with a valid K2 record and a content match', () => {
|
||||
const { fx, oldId, carry } = cryFlow();
|
||||
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
|
||||
const good = fx.read();
|
||||
assert.equal(fx.gate().status, 0, 'the carried row opens the gate');
|
||||
// Control: the same archive bytes under another run's name.
|
||||
const other = 'f'.repeat(12);
|
||||
copyFileSync(fx.archive(1), fx.archive(1, other));
|
||||
fx.write(editRow(good, (c) => [...c.slice(0, 4), `review-round-${other}-1.md#${oldId}`]));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 2, r.stdout);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
|
||||
});
|
||||
|
||||
test('[CRY] a row linking to an archive whose <n> is not lower than the review\'s round keeps exit 2', () => {
|
||||
const { fx, oldId, carry } = cryFlow();
|
||||
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
|
||||
const good = fx.read();
|
||||
assert.equal(fx.gate().status, 0, 'the carried row opens the gate');
|
||||
// Control: the same archive bytes as round 2 of this run, in a round-2 review.
|
||||
copyFileSync(fx.archive(1), fx.archive(2));
|
||||
fx.write(editRow(good, (c) => [...c.slice(0, 4), `review-round-${fx.run}-2.md#${oldId}`]));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 2, r.stdout);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
|
||||
// Control: a review with no round field accepts no carried link.
|
||||
fx.write(good.replace(/^round: 2\n/m, ''));
|
||||
const n = fx.gate();
|
||||
assert.equal(n.status, 2, n.stdout);
|
||||
assert.match(String(parse(n).reason), /^review-unanswered:/, n.stdout);
|
||||
});
|
||||
|
||||
test('[CRY] an answer to one carried occurrence, linked from another carried occurrence of the same class, keeps exit 2', () => {
|
||||
const fx = rndRepo();
|
||||
const Y1 = { severity: 'MAJOR', file: 'a.mjs', line: 3, title: 'Parser drops the last token', detail: 'parse() stops one token early' };
|
||||
const Y2 = { severity: 'MAJOR', file: 'a.mjs', line: 5, title: 'Cache never expires entries', detail: 'ttl ignored in get()' };
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [Y1, Y2] }).text);
|
||||
fx.out(CCR);
|
||||
const f1 = fx.finalize(1);
|
||||
assert.equal(f1.status, 0, f1.stdout + f1.stderr);
|
||||
const [id1, id2] = jsonOf(fx.read()).findings.map((x) => x.id);
|
||||
const rec = run([ANSWERS, 'record', '--review', fx.reviewPath, '--finding', id1, '--answer', 'accepted', '--repo', fx.repo,
|
||||
'--round', '1', '--store', 'local', '--trailer', TRAILER], { input: 'the parser keeps the old contract on purpose' });
|
||||
assert.equal(rec.status, 0, `record: ${rec.stdout}${rec.stderr}`);
|
||||
const p = fx.prepare();
|
||||
assert.equal(p.status, 0, p.stdout + p.stderr);
|
||||
roundTwo(fx, [Y1, Y2], { outs: { [CCR]: [] } });
|
||||
const f2 = fx.finalize2();
|
||||
assert.equal(f2.status, 0, f2.stdout + f2.stderr);
|
||||
const carry = run([ANSWERS, 'carry', '--review', fx.reviewPath, '--previous', fx.archive(1), '--repo', fx.repo]);
|
||||
assert.equal(carry.status, 0, `review-answers carry: ${carry.stdout}${carry.stderr}`);
|
||||
const good = fx.read();
|
||||
const g1 = jsonOf(good).findings.find((x) => x.carried_from === id1);
|
||||
const g2 = jsonOf(good).findings.find((x) => x.carried_from === id2);
|
||||
assert.ok(g1 && g2, `both MAJORs are carried: ${JSON.stringify(jsonOf(good).findings)}`);
|
||||
const row = good.split('\n').find((l) => l.startsWith(`| ${g1.id} | accepted |`));
|
||||
assert.ok(row && row.endsWith(`| review-round-${fx.run}-1.md#${id1} |`), `the carried row of the answered MAJOR:\n${good}`);
|
||||
// Control: only the answered occurrence is answered.
|
||||
const c = fx.gate();
|
||||
assert.equal(c.status, 2, c.stdout);
|
||||
assert.deepEqual(parse(c).unanswered, [g2.id], c.stdout);
|
||||
// The same row copied to the other occurrence: its link names id1, the finding was carried from id2.
|
||||
fx.write(good.replace(row, `${row}\n${row.replace(`| ${g1.id} |`, `| ${g2.id} |`)}`));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 2, r.stdout);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
|
||||
assert.deepEqual(parse(r).unanswered, [g2.id], r.stdout);
|
||||
// Control: round 3 carries the answer through the round-2 archive; a copy for the other occurrence does not.
|
||||
fx.write(good);
|
||||
const p3 = fx.prepare();
|
||||
assert.equal(p3.status, 0, `Control: ${p3.stdout}${p3.stderr}`);
|
||||
assert.equal(parse(p3).round, 3, p3.stdout);
|
||||
fx.write(rndReview({ start: fx.start, end: fx.c2, findings: [Y1, Y2] }).text);
|
||||
fx.out(CCR, []);
|
||||
const f3 = fx.finalize(3, ['--round-base', fx.c2, '--previous', fx.archive(2)]);
|
||||
assert.equal(f3.status, 0, `Control: ${f3.stdout}${f3.stderr}`);
|
||||
const carry3 = run([ANSWERS, 'carry', '--review', fx.reviewPath, '--previous', fx.archive(2), '--repo', fx.repo]);
|
||||
assert.equal(carry3.status, 0, `Control: ${carry3.stdout}${carry3.stderr}`);
|
||||
const third = fx.read();
|
||||
const h1 = jsonOf(third).findings.find((x) => x.carried_from === g1.id);
|
||||
const h2 = jsonOf(third).findings.find((x) => x.carried_from === g2.id);
|
||||
assert.ok(h1 && h2, `Control: both MAJORs are carried into round 3: ${JSON.stringify(jsonOf(third).findings)}`);
|
||||
const row3 = third.split('\n').find((l) => l.startsWith(`| ${h1.id} | accepted |`));
|
||||
assert.ok(row3 && row3.endsWith(`| review-round-${fx.run}-1.md#${id1} |`), `Control: the round-3 row links where the answer was given:\n${third}`);
|
||||
const c3 = fx.gate();
|
||||
assert.deepEqual(parse(c3).unanswered, [h2.id], `Control: ${c3.stdout}`);
|
||||
fx.write(third.replace(row3, `${row3}\n${row3.replace(`| ${h1.id} |`, `| ${h2.id} |`)}`));
|
||||
const r3 = fx.gate();
|
||||
assert.equal(r3.status, 2, r3.stdout);
|
||||
assert.deepEqual(parse(r3).unanswered, [h2.id], r3.stdout);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue