446 lines
22 KiB
JavaScript
446 lines
22 KiB
JavaScript
// 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');
|
|
g(repo, 'checkout', '-q', '-b', 'feat');
|
|
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');
|
|
g(fx.repo, 'branch', '-f', 'main', start);
|
|
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);
|
|
});
|