feat(review): pre-existing findings marked from git and re-derived by the gate
This commit is contained in:
parent
ce7dbc27a9
commit
1464605e35
7 changed files with 1015 additions and 5 deletions
2
tests/fixtures/jsonl-schemas.md
vendored
2
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -25,7 +25,7 @@
|
|||
| 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 (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:643-693 | 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:654-704 | 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) |
|
||||
|
||||
|
|
|
|||
444
tests/lib/pre-existing.test.mjs
Normal file
444
tests/lib/pre-existing.test.mjs
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
// tests/lib/pre-existing.test.mjs
|
||||
// [PRX] fix round 4.5b2 part B, Step 5: a finding on a line the range did not
|
||||
// add or change is marked pre-existing (lib/review/review-finalize.mjs, from
|
||||
// git through lib/review/pre-existing.mjs), and the verdict gate re-derives the
|
||||
// mark from git: it never trusts it as written. A pre-existing MAJOR leaves the
|
||||
// answer set; a BLOCKER or a security finding counts in full whatever the mark;
|
||||
// every uncertain case (a renamed file, line 0, no scope_sha_start, a changed
|
||||
// line, a line next to a pure deletion, an uncommitted file) counts as new.
|
||||
// Tests run the CLIs (gate, finalize, fill-finding-ids, validator) in throwaway
|
||||
// git repos with no hooks; `changedLineSets` is also read directly for the
|
||||
// repo-config case. `keep:` cases hold at the base already and stay untagged.
|
||||
// Each tagged gate case carries a Control: the same finding on an unchanged
|
||||
// line exits 0, which only a gate that reads the mark can do.
|
||||
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, 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 GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||
const PRE = join(ROOT, 'lib', 'review', 'pre-existing.mjs');
|
||||
const FINALIZE = join(ROOT, 'lib', 'review', 'review-finalize.mjs');
|
||||
const FILL = join(ROOT, 'lib', 'review', 'fill-finding-ids.mjs');
|
||||
const VALIDATOR = join(ROOT, 'lib', 'validators', 'review-validator.mjs');
|
||||
const REVIEW_REL = '.claude/projects/2026-11-02-p/review.md';
|
||||
|
||||
// Loaded so that each new assertion fails on its own while the module is absent.
|
||||
let preMod = {};
|
||||
try {
|
||||
preMod = await import(pathToFileURL(PRE).href);
|
||||
} catch (e) {
|
||||
if (existsSync(PRE)) 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 {}; } };
|
||||
// Ten lines `export const <tag><n> = <n>;`; `changes` maps a line number to new text, or to null to remove it.
|
||||
const ten = (tag, changes = {}) => Array.from({ length: 10 }, (_, i) => (i + 1 in changes ? changes[i + 1] : `export const ${tag}${i + 1} = ${i + 1};`))
|
||||
.filter((l) => l !== null).join('\n') + '\n';
|
||||
|
||||
/**
|
||||
* A throwaway repo. The start commit holds a.mjs, d.mjs and old.mjs (ten lines
|
||||
* each). `rounds` are later commits, each a map of path → text (null removes,
|
||||
* `{ mv: from }` renames). The default single round:
|
||||
* - a.mjs line 3 changed;
|
||||
* - d.mjs line 5 removed (a pure deletion: new lines 4 and 5 sit next to it);
|
||||
* - old.mjs renamed to moved.mjs, unchanged;
|
||||
* - n.mjs added.
|
||||
* `config` lines go into .git/config before the rounds are committed.
|
||||
*/
|
||||
function prxRepo({ config = [], rounds } = {}) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'pre-existing-prx-'));
|
||||
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');
|
||||
putIn(repo, 'a.mjs', ten('a'));
|
||||
putIn(repo, 'd.mjs', ten('d'));
|
||||
putIn(repo, 'old.mjs', ten('o'));
|
||||
g(repo, 'add', '-A');
|
||||
g(repo, 'commit', '-q', '-m', 'chore: start');
|
||||
const start = g(repo, 'rev-parse', 'HEAD');
|
||||
for (const c of config) g(repo, 'config', ...c);
|
||||
const plan = rounds ?? [{
|
||||
'a.mjs': ten('a', { 3: 'export const a3 = 33;' }),
|
||||
'd.mjs': ten('d', { 5: null }),
|
||||
'moved.mjs': { mv: 'old.mjs' },
|
||||
'n.mjs': 'export const n = 1;\n',
|
||||
}];
|
||||
const shas = [];
|
||||
plan.forEach((files, i) => {
|
||||
for (const [rel, text] of Object.entries(files)) {
|
||||
if (text === null) g(repo, 'rm', '-q', rel);
|
||||
else if (typeof text === 'object') g(repo, 'mv', text.mv, rel);
|
||||
else putIn(repo, rel, text);
|
||||
}
|
||||
g(repo, 'add', '-A');
|
||||
g(repo, 'commit', '-q', '-m', `feat: round ${i + 1}`);
|
||||
shas.push(g(repo, 'rev-parse', 'HEAD'));
|
||||
});
|
||||
const reviewPath = join(repo, ...REVIEW_REL.split('/'));
|
||||
return {
|
||||
repo, start, shas, end: shas.at(-1), reviewPath,
|
||||
write: (text) => putIn(repo, REVIEW_REL, text),
|
||||
read: () => readFileSync(reviewPath, 'utf8'),
|
||||
gate: () => spawnSync(process.execPath, [GATE, '--json', reviewPath, '--repo', repo], { encoding: 'utf8', env: childEnv() }),
|
||||
finalize: (extra = []) => spawnSync(process.execPath, [FINALIZE, '--review', reviewPath, '--repo', repo,
|
||||
'--project', dirname(reviewPath), '--round', '1', ...extra], { encoding: 'utf8', env: childEnv() }),
|
||||
fill: () => spawnSync(process.execPath, [FILL, reviewPath], { encoding: 'utf8', env: childEnv() }),
|
||||
validate: () => spawnSync(process.execPath, [VALIDATOR, '--strict', reviewPath], { encoding: 'utf8', env: childEnv() }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A review_version 1.2 review of `start..end`. `findings` are
|
||||
* [{severity, file, line, rule_key?, engine?, pre_existing?, title?}]; ids are
|
||||
* computed over (file, line, rule_key) unless `ids` names them. `start: null`
|
||||
* writes `scope_sha_start: null`; `round`/`roundBase` add the round fields.
|
||||
*/
|
||||
function prxReview({ start, end, findings, verdict, ids, round, roundBase }) {
|
||||
const list = findings.map((f, i) => ({
|
||||
severity: f.severity, rule_key: f.rule_key ?? 'MISSING_TEST', ...(f.engine ? { engine: f.engine } : {}),
|
||||
file: f.file, line: f.line, title: f.title ?? `t${i + 1}`, detail: `d${i + 1}`, recommended_action: 'Add a test.',
|
||||
...('pre_existing' in f ? { pre_existing: f.pre_existing } : {}),
|
||||
}));
|
||||
const computed = ids ?? computeFindingIds(list);
|
||||
list.forEach((f, i) => { f.id = computed[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- **Rule:** \`${f.rule_key}\`\n- **Location:** \`${f.file}:${f.line}\`\n- **Title:** ${f.title}\n`).join('\n')}\n`;
|
||||
}).join('');
|
||||
const steps = ordered.filter((f) => f.severity === 'BLOCKER' || f.severity === 'MAJOR')
|
||||
.map((f, i) => `### Step R${i + 1}: fix ${f.title}\n\n- **Files:** \`${f.file}\`\n- **Source finding:** \`${f.id}\`\n- **Test first:** a test that fails today\n- **Verify:** re-run /trekreview\n- **On failure:** escalate\n`).join('\n');
|
||||
return `---
|
||||
type: trekreview
|
||||
review_version: "1.2"
|
||||
task: "t"
|
||||
slug: p
|
||||
project_dir: .claude/projects/2026-11-02-p/
|
||||
brief_path: .claude/projects/2026-11-02-p/brief.md
|
||||
scope_sha_start: ${start === null ? 'null' : start}
|
||||
scope_sha_end: ${end}
|
||||
reviewed_files_count: 3
|
||||
verdict: ${v}
|
||||
${round ? `round: ${round}\nround_base: ${roundBase ?? 'null'}\n` : ''}findings:
|
||||
${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 Summary
|
||||
|
||||
x
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
${steps || 'None.\n'}
|
||||
\`\`\`json
|
||||
${json}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
const jsonOf = (text) => JSON.parse(text.slice(text.lastIndexOf('```json\n') + 8, text.lastIndexOf('```')));
|
||||
|
||||
/** Write a one-finding WARN review of the fixture's range and run the gate on it. */
|
||||
function gateOn(fx, finding, opts = {}) {
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings: [{ severity: 'MAJOR', ...finding }], ...opts }));
|
||||
return fx.gate();
|
||||
}
|
||||
|
||||
function assertUnanswered(r) {
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered:/, r.stdout);
|
||||
}
|
||||
|
||||
function assertControl(fx, finding = { file: 'a.mjs', line: 7, pre_existing: true }) {
|
||||
const r = gateOn(fx, finding);
|
||||
assert.equal(r.status, 0, `Control: a MAJOR marked pre-existing on an unchanged line opens the gate: ${r.stdout}${r.stderr}`);
|
||||
}
|
||||
|
||||
test('[PRX] a MAJOR outside the -U0 added or changed lines is marked pre-existing and leaves the answer set', () => {
|
||||
assert.ok(existsSync(FINALIZE), 'lib/review/review-finalize.mjs must exist');
|
||||
const fx = prxRepo();
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings: [{ severity: 'MAJOR', file: 'a.mjs', line: 7, title: 'An old defect' }] }));
|
||||
const f = fx.finalize();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const text = fx.read();
|
||||
const payload = jsonOf(text);
|
||||
assert.equal(payload.findings[0].pre_existing, true, text);
|
||||
assert.deepEqual(payload.pre_existing_counts, { BLOCKER: 0, MAJOR: 1, MINOR: 0, SUGGESTION: 0 });
|
||||
assert.equal(payload.findings[0].severity, 'MAJOR', 'the mark never lowers a finding');
|
||||
const pre = text.search(/^## Pre-existing\s*$/m);
|
||||
const declined = text.search(/^## Declined to Judge\s*$/m);
|
||||
assert.ok(pre !== -1 && pre < declined, `## Pre-existing stands just before ## Declined to Judge:\n${text}`);
|
||||
assert.ok(text.slice(pre, declined).includes('An old defect'), text.slice(pre, declined));
|
||||
assert.ok(!/^## Answers/m.test(text), 'no answer was written');
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
test('[PRX] a finding in a renamed file, on line 0, with no scope_sha_start, on a changed line or next to a pure deletion counts as new', () => {
|
||||
assert.equal(typeof preMod.changedLineSets, 'function', 'pre-existing.mjs exports changedLineSets');
|
||||
const fx = prxRepo();
|
||||
assertControl(fx);
|
||||
const cases = [
|
||||
['a renamed file', { file: 'moved.mjs', line: 5, pre_existing: true }],
|
||||
['line 0', { file: 'a.mjs', line: 0, pre_existing: true }],
|
||||
['a changed line', { file: 'a.mjs', line: 3, pre_existing: true }],
|
||||
['the line before a pure deletion', { file: 'd.mjs', line: 4, pre_existing: true }],
|
||||
['the line after a pure deletion', { file: 'd.mjs', line: 5, pre_existing: true }],
|
||||
['a new file', { file: 'n.mjs', line: 1, pre_existing: true }],
|
||||
];
|
||||
for (const [what, finding] of cases) {
|
||||
const r = gateOn(fx, finding);
|
||||
assert.equal(r.status, 2, `${what}: ${r.stdout}${r.stderr}`);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered:/, `${what}: ${r.stdout}`);
|
||||
}
|
||||
const none = gateOn(fx, { file: 'a.mjs', line: 7, pre_existing: true }, { start: null });
|
||||
assert.equal(none.status, 2, `no scope_sha_start: ${none.stdout}${none.stderr}`);
|
||||
assert.match(String(parse(none).reason), /^review-unanswered:/, none.stdout);
|
||||
// Control: a line well away from the deletion is still pre-existing (the file is not 'all').
|
||||
assertControl(fx, { file: 'd.mjs', line: 8, pre_existing: true });
|
||||
});
|
||||
|
||||
test('[PRX] finalize marks none of the uncertain cases, and no finding in a file with uncommitted changes', () => {
|
||||
assert.ok(existsSync(FINALIZE), 'lib/review/review-finalize.mjs must exist');
|
||||
const fx = prxRepo();
|
||||
const findings = [
|
||||
{ severity: 'MAJOR', file: 'moved.mjs', line: 5 },
|
||||
{ severity: 'MAJOR', file: 'a.mjs', line: 3 },
|
||||
{ severity: 'MAJOR', file: 'd.mjs', line: 4 },
|
||||
{ severity: 'MAJOR', file: 'd.mjs', line: 5 },
|
||||
{ severity: 'MINOR', file: 'a.mjs', line: 0 },
|
||||
{ severity: 'MAJOR', file: 'd.mjs', line: 8 },
|
||||
];
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings }));
|
||||
const f = fx.finalize();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
assert.deepEqual(jsonOf(fx.read()).findings.map((x) => x.pre_existing), [false, false, false, false, false, true]);
|
||||
// An uncommitted change in d.mjs: its findings are not marked.
|
||||
putIn(fx.repo, 'd.mjs', ten('d', { 5: null, 9: 'export const d10 = 0;' }));
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings: [{ severity: 'MAJOR', file: 'd.mjs', line: 8 }] }));
|
||||
const u = fx.finalize();
|
||||
assert.equal(u.status, 0, u.stdout + u.stderr);
|
||||
assert.equal(jsonOf(fx.read()).findings[0].pre_existing, false, fx.read());
|
||||
// No scope_sha_start: nothing is marked.
|
||||
fx.write(prxReview({ start: null, end: fx.end, findings: [{ severity: 'MAJOR', file: 'a.mjs', line: 7 }] }));
|
||||
const n = fx.finalize();
|
||||
assert.equal(n.status, 0, n.stdout + n.stderr);
|
||||
assert.equal(jsonOf(fx.read()).findings[0].pre_existing, false, fx.read());
|
||||
});
|
||||
|
||||
test('[PRX] in a round-2 review whose scope_sha_start is the run\'s start, a MAJOR on a line round 1 added counts as new', () => {
|
||||
assert.equal(typeof preMod.changedLineSets, 'function', 'pre-existing.mjs exports changedLineSets');
|
||||
const fx = prxRepo({ rounds: [
|
||||
{ 'a.mjs': ten('a', { 5: 'export const a5 = 55;' }) },
|
||||
{ 'a.mjs': ten('a', { 5: 'export const a5 = 55;', 9: 'export const a9 = 99;' }) },
|
||||
] });
|
||||
const [round1] = fx.shas;
|
||||
const opts = { round: 2, roundBase: round1 };
|
||||
const r = gateOn(fx, { file: 'a.mjs', line: 5, pre_existing: true }, opts);
|
||||
assertUnanswered(r);
|
||||
// Control: an unchanged line of the same file is pre-existing in round 2.
|
||||
const c = gateOn(fx, { file: 'a.mjs', line: 7, pre_existing: true }, opts);
|
||||
assert.equal(c.status, 0, `Control: ${c.stdout}${c.stderr}`);
|
||||
// Finalize marks by the run range too, whatever --round-base says.
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings: [{ severity: 'MAJOR', file: 'a.mjs', line: 5 }], ...opts }));
|
||||
const f = fx.finalize(['--round-base', round1]);
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
assert.equal(jsonOf(fx.read()).findings[0].pre_existing, false, fx.read());
|
||||
});
|
||||
|
||||
test('[PRX] a pre-existing security MAJOR (CODE_REVIEW_DEFECT from security-review) still exits 2 with review-unanswered:', () => {
|
||||
const fx = prxRepo();
|
||||
// Control: the same key from the code-review engine is pre-existing and opens the gate.
|
||||
assertControl(fx, { file: 'a.mjs', line: 7, rule_key: 'CODE_REVIEW_DEFECT', engine: 'code-review', pre_existing: true });
|
||||
const r = gateOn(fx, { file: 'a.mjs', line: 7, rule_key: 'CODE_REVIEW_DEFECT', engine: 'security-review', pre_existing: true });
|
||||
assertUnanswered(r);
|
||||
});
|
||||
|
||||
test('[PRX] after finalize every id is the computed one and the gate reads the file', () => {
|
||||
assert.ok(existsSync(FINALIZE), 'lib/review/review-finalize.mjs must exist');
|
||||
const fx = prxRepo();
|
||||
const findings = [
|
||||
{ severity: 'MAJOR', file: 'a.mjs', line: 7, title: 'An old defect' },
|
||||
{ severity: 'MINOR', file: 'a.mjs', line: 3, title: 'A new nit' },
|
||||
];
|
||||
// The MAJOR carries an id over an old line (8): finalize recomputes it.
|
||||
const stale = computeFindingId('a.mjs', 8, 'MISSING_TEST');
|
||||
const good = computeFindingId('a.mjs', 3, 'MISSING_TEST');
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings, ids: [stale, good] }));
|
||||
const f = fx.finalize();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const text = fx.read();
|
||||
const want = computeFindingId('a.mjs', 7, 'MISSING_TEST');
|
||||
assert.deepEqual(jsonOf(text).findings.map((x) => x.id), [want, good]);
|
||||
assert.ok(!text.includes(stale), `no stale id is left anywhere:\n${text}`);
|
||||
assert.ok(text.includes(` - ${want}\n`) && text.includes(`### ${want}\n`) && text.includes(`**Source finding:** \`${want}\``), text);
|
||||
const fill = fx.fill();
|
||||
assert.equal(fill.status, 0, `fill-finding-ids has nothing left to fill: ${fill.stdout}${fill.stderr}`);
|
||||
assert.equal(fx.read(), text, 'fill-finding-ids leaves the finalized file unchanged');
|
||||
const v = fx.validate();
|
||||
assert.equal(v.status, 0, `review-validator --strict: ${v.stdout}${v.stderr}`);
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.doesNotMatch(String(parse(r).reason), /differ/, r.stdout);
|
||||
});
|
||||
|
||||
test('[PRX] a forged pre_existing mark on an added line still needs an answer', () => {
|
||||
const fx = prxRepo();
|
||||
assertControl(fx);
|
||||
assertUnanswered(gateOn(fx, { file: 'a.mjs', line: 3, pre_existing: true }));
|
||||
assertUnanswered(gateOn(fx, { file: 'n.mjs', line: 1, pre_existing: true }));
|
||||
// A path the diff cannot name the same way is never pre-existing.
|
||||
assertUnanswered(gateOn(fx, { file: './a.mjs', line: 3, pre_existing: true }));
|
||||
});
|
||||
|
||||
test('[PRX] a path or line that is not in the tree at scope_sha_end counts as new, at the gate and in finalize', () => {
|
||||
assert.ok(existsSync(FINALIZE), 'lib/review/review-finalize.mjs must exist');
|
||||
// The default round plus a new sub/x.mjs (ten lines). a.mjs has ten lines at the end.
|
||||
const fx = prxRepo({ rounds: [{
|
||||
'a.mjs': ten('a', { 3: 'export const a3 = 33;' }),
|
||||
'd.mjs': ten('d', { 5: null }),
|
||||
'moved.mjs': { mv: 'old.mjs' },
|
||||
'n.mjs': 'export const n = 1;\n',
|
||||
'sub/x.mjs': ten('x'),
|
||||
}] });
|
||||
// Control: the last line of a.mjs is in the file and unchanged, so it is pre-existing.
|
||||
assertControl(fx, { file: 'a.mjs', line: 10, pre_existing: true });
|
||||
const cases = [
|
||||
['a case variant of a changed file (the disk may be case-insensitive)', { file: 'A.mjs', line: 3 }],
|
||||
['a file missing at scope_sha_end', { file: 'zzz.mjs', line: 5 }],
|
||||
['a subdirectory-relative path to the added sub/x.mjs', { file: 'x.mjs', line: 3 }],
|
||||
['a case variant of the added sub/x.mjs', { file: 'Sub/x.mjs', line: 3 }],
|
||||
['a line past the end of the file', { file: 'a.mjs', line: 500 }],
|
||||
['the line just past the end of the file', { file: 'a.mjs', line: 11 }],
|
||||
];
|
||||
for (const [what, finding] of cases) {
|
||||
const r = gateOn(fx, { ...finding, pre_existing: true });
|
||||
assert.equal(r.status, 2, `${what}: ${r.stdout}${r.stderr}`);
|
||||
assert.match(String(parse(r).reason), /^review-unanswered:/, `${what}: ${r.stdout}`);
|
||||
}
|
||||
const findings = [...cases.map(([, f]) => ({ severity: 'MAJOR', ...f })), { severity: 'MAJOR', file: 'a.mjs', line: 10 }];
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings }));
|
||||
const f = fx.finalize();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const marks = jsonOf(fx.read()).findings.map((x) => `${x.file}:${x.line}=${x.pre_existing}`);
|
||||
assert.deepEqual(marks, ['A.mjs:3=false', 'zzz.mjs:5=false', 'x.mjs:3=false', 'Sub/x.mjs:3=false',
|
||||
'a.mjs:500=false', 'a.mjs:11=false', 'a.mjs:10=true']);
|
||||
});
|
||||
|
||||
test('[PRX] a symlink to a file the range changed is not a regular file: a finding through it counts as new', () => {
|
||||
assert.ok(existsSync(FINALIZE), 'lib/review/review-finalize.mjs must exist');
|
||||
// The fixture's own range is not used: the run starts at a commit that adds
|
||||
// lnk.mjs -> a.mjs, and ends at one that changes line 1 of a.mjs. The link is
|
||||
// unchanged in the range, so only the tree mode at the end tells it apart.
|
||||
// Line 1 is cited because the link's blob ("a.mjs") is one line long.
|
||||
const fx = prxRepo();
|
||||
symlinkSync('a.mjs', join(fx.repo, 'lnk.mjs'));
|
||||
g(fx.repo, 'add', '--', 'lnk.mjs');
|
||||
g(fx.repo, 'commit', '-q', '-m', 'chore: link');
|
||||
const start = g(fx.repo, 'rev-parse', 'HEAD');
|
||||
assert.equal(g(fx.repo, 'ls-tree', start, '--', 'lnk.mjs').split(' ')[0], '120000', 'lnk.mjs is committed as a symlink');
|
||||
putIn(fx.repo, 'a.mjs', ten('a', { 1: 'export const a1 = 11;' }));
|
||||
g(fx.repo, 'add', '--', 'a.mjs');
|
||||
g(fx.repo, 'commit', '-q', '-m', 'feat: change a.mjs line 1');
|
||||
const end = g(fx.repo, 'rev-parse', 'HEAD');
|
||||
const range = { start, end };
|
||||
// Control: an unchanged line of a.mjs is pre-existing in this range.
|
||||
const c = gateOn(fx, { file: 'a.mjs', line: 7, pre_existing: true }, range);
|
||||
assert.equal(c.status, 0, `Control: ${c.stdout}${c.stderr}`);
|
||||
// Control: the changed line through its real path counts as new.
|
||||
assertUnanswered(gateOn(fx, { file: 'a.mjs', line: 1, pre_existing: true }, range));
|
||||
assertUnanswered(gateOn(fx, { file: 'lnk.mjs', line: 1, pre_existing: true }, range));
|
||||
fx.write(prxReview({ ...range, findings: [
|
||||
{ severity: 'MAJOR', file: 'lnk.mjs', line: 1 },
|
||||
{ severity: 'MAJOR', file: 'a.mjs', line: 7 },
|
||||
] }));
|
||||
const f = fx.finalize();
|
||||
assert.equal(f.status, 0, f.stdout + f.stderr);
|
||||
const marks = jsonOf(fx.read()).findings.map((x) => `${x.file}:${x.line}=${x.pre_existing}`);
|
||||
assert.deepEqual(marks, ['lnk.mjs:1=false', 'a.mjs:7=true']);
|
||||
});
|
||||
|
||||
test('[PRX] repo diff config does not change the line sets', () => {
|
||||
assert.equal(typeof preMod.changedLineSets, 'function', 'pre-existing.mjs exports changedLineSets');
|
||||
const view = (s) => ({
|
||||
ok: s.ok,
|
||||
files: Object.fromEntries([...s.files].filter(([, v]) => v === 'all' || v.size > 0)
|
||||
.map(([k, v]) => [k, v === 'all' ? 'all' : [...v].sort((x, y) => x - y)]).sort()),
|
||||
renamed: [...s.renamed].sort(),
|
||||
});
|
||||
const want = { ok: true, files: { 'a.mjs': [3], 'd.mjs': [4, 5], 'n.mjs': 'all' }, renamed: ['moved.mjs', 'old.mjs'] };
|
||||
const plain = prxRepo();
|
||||
assert.deepEqual(view(preMod.changedLineSets(plain.repo, plain.start, plain.end)), want);
|
||||
const fx = prxRepo({ config: [['diff.renames', 'false'], ['color.diff', 'always'], ['diff.noprefix', 'true']] });
|
||||
assert.deepEqual(view(preMod.changedLineSets(fx.repo, fx.start, fx.end)), want);
|
||||
assertControl(fx);
|
||||
assertUnanswered(gateOn(fx, { file: 'moved.mjs', line: 5, pre_existing: true }));
|
||||
assertUnanswered(gateOn(fx, { file: 'a.mjs', line: 3, pre_existing: true }));
|
||||
});
|
||||
|
||||
test('keep: a pre-existing BLOCKER still exits 1', () => {
|
||||
const fx = prxRepo();
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings: [{ severity: 'BLOCKER', file: 'a.mjs', line: 7, pre_existing: true }] }));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
test('keep: the verdict gate accepts the pre_existing field without exit 2', () => {
|
||||
const fx = prxRepo();
|
||||
fx.write(prxReview({ start: fx.start, end: fx.end, findings: [
|
||||
{ severity: 'MINOR', file: 'a.mjs', line: 7, pre_existing: true },
|
||||
{ severity: 'MINOR', file: 'a.mjs', line: 3, pre_existing: false },
|
||||
] }));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue