// tests/lib/plan-review-dedup.test.mjs // Cover lib/review/plan-review-dedup.mjs: // - identical findings dedupe to 1 (exact-id path) // - distinct findings stay separate // - jaccard threshold 0.7 catches near-duplicates // - empty / missing payloads tolerated // - CLI shim emits parseable JSON on stdout import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync } from 'node:child_process'; import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dedupFindings, tokenize, DEFAULT_THRESHOLD } from '../../lib/review/plan-review-dedup.mjs'; // Namespace import: a missing export reads as undefined and fails an assertion, // it does not crash the whole file at link time. import * as dedupModule from '../../lib/review/plan-review-dedup.mjs'; import { computeFindingId } from '../../lib/parsers/finding-id.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SHIM = join(HERE, '..', '..', 'lib', 'review', 'plan-review-dedup.mjs'); function tmp(prefix = 'plan-review-dedup-') { return mkdtempSync(join(tmpdir(), prefix)); } test('tokenize splits on non-word and lowercases', () => { assert.deepEqual( tokenize('Step 4 LACKS verifiable acceptance!'), ['step', '4', 'lacks', 'verifiable', 'acceptance'], ); assert.deepEqual(tokenize(''), []); assert.deepEqual(tokenize(undefined), []); }); test('DEFAULT_THRESHOLD is 0.7 per plan-v2 spec', () => { assert.equal(DEFAULT_THRESHOLD, 0.7); }); test('identical findings (same file/line/rule_key) dedupe to 1, raised_by merged', () => { const sources = [ { agent: 'plan-critic', payload: { agent: 'plan-critic', findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', text: 'Step 4 lacks verifiable acceptance criteria' }] } }, { agent: 'scope-guardian', payload: { agent: 'scope-guardian', findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', text: 'Step 4 lacks verifiable acceptance criteria' }] } }, ]; const r = dedupFindings(sources); assert.equal(r.findings.length, 1); assert.deepEqual(r.findings[0].raised_by.sort(), ['plan-critic', 'scope-guardian']); assert.equal(r.dedup_stats.total_in, 2); assert.equal(r.dedup_stats.total_out, 1); assert.equal(r.dedup_stats.exact_id_dups, 1); }); test('distinct findings (different file/line/rule_key) stay separate', () => { const sources = [ { agent: 'plan-critic', payload: { findings: [ { file: 'plan.md', line: 10, rule_key: 'PC1', text: 'thing one' }, { file: 'plan.md', line: 20, rule_key: 'PC2', text: 'thing two unrelated entirely' }, ] } }, ]; const r = dedupFindings(sources); assert.equal(r.findings.length, 2); assert.equal(r.dedup_stats.exact_id_dups, 0); assert.equal(r.dedup_stats.jaccard_dups, 0); }); test('jaccard ≥ 0.7 on near-duplicate text merges (different file/line so id differs)', () => { const sources = [ { agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 10, rule_key: 'PC1', text: 'step lacks verifiable acceptance criteria for path A' }] } }, { agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 11, rule_key: 'SG1', text: 'step lacks verifiable acceptance criteria for path A' }] } }, ]; const r = dedupFindings(sources); assert.equal(r.findings.length, 1, 'jaccard merge should collapse near-duplicates'); assert.deepEqual(r.findings[0].raised_by.sort(), ['plan-critic', 'scope-guardian']); assert.equal(r.dedup_stats.jaccard_dups, 1); }); test('jaccard below threshold keeps both findings separate', () => { const sources = [ { agent: 'plan-critic', payload: { findings: [{ file: 'a.md', line: 1, rule_key: 'PC1', text: 'database migration risk' }] } }, { agent: 'scope-guardian', payload: { findings: [{ file: 'b.md', line: 2, rule_key: 'SG1', text: 'unrelated frontend hover state polish' }] } }, ]; const r = dedupFindings(sources); assert.equal(r.findings.length, 2); assert.equal(r.dedup_stats.jaccard_dups, 0); }); test('empty / missing payloads tolerated (single-agent input)', () => { const r = dedupFindings([ { agent: 'plan-critic', payload: { findings: [{ file: 'a.md', line: 1, rule_key: 'PC1', text: 'one' }] } }, { agent: 'scope-guardian', payload: null }, ]); assert.equal(r.findings.length, 1); assert.deepEqual(r.findings[0].raised_by, ['plan-critic']); }); test('all sources empty → empty result, dedup_stats zeros', () => { const r = dedupFindings([ { agent: 'plan-critic', payload: null }, { agent: 'scope-guardian', payload: { findings: [] } }, ]); assert.equal(r.findings.length, 0); assert.equal(r.dedup_stats.total_in, 0); assert.equal(r.dedup_stats.total_out, 0); }); test('CLI shim parses input files and emits valid deduped JSON', () => { const dir = tmp(); try { const planCritic = join(dir, 'pc.json'); const scopeGuardian = join(dir, 'sg.json'); writeFileSync(planCritic, JSON.stringify({ agent: 'plan-critic', findings: [{ file: 'plan.md', line: 5, rule_key: 'PC1', text: 'duplicate finding shared by both' }], })); writeFileSync(scopeGuardian, JSON.stringify({ agent: 'scope-guardian', findings: [{ file: 'plan.md', line: 5, rule_key: 'PC1', text: 'duplicate finding shared by both' }], })); const out = execFileSync(process.execPath, [ SHIM, '--plan-critic', planCritic, '--scope-guardian', scopeGuardian, ], { encoding: 'utf-8' }); const parsed = JSON.parse(out); assert.equal(parsed.findings.length, 1); assert.deepEqual(parsed.findings[0].raised_by.sort(), ['plan-critic', 'scope-guardian']); assert.equal(parsed.dedup_stats.total_out, 1); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('CLI shim tolerates missing input files (returns empty deduped JSON)', () => { const out = execFileSync(process.execPath, [SHIM], { encoding: 'utf-8' }); const parsed = JSON.parse(out); assert.equal(parsed.findings.length, 0); assert.equal(parsed.dedup_stats.total_in, 0); }); // --- S23 — --stdin mode (Phase-9 dedup fix: read-only reviewers can't write // temp files, so the orchestrator pipes both inline JSON blocks via stdin) --- test('CLI --stdin merges {plan_critic, scope_guardian} payloads, provenance preserved', () => { const stdin = JSON.stringify({ plan_critic: { agent: 'plan-critic', findings: [{ file: 'plan.md', line: 5, rule_key: 'PC1', text: 'duplicate finding shared by both' }], }, scope_guardian: { agent: 'scope-guardian', findings: [{ file: 'plan.md', line: 5, rule_key: 'PC1', text: 'duplicate finding shared by both' }], }, }); const out = execFileSync(process.execPath, [SHIM, '--stdin'], { input: stdin, encoding: 'utf-8' }); const parsed = JSON.parse(out); assert.equal(parsed.findings.length, 1); assert.deepEqual(parsed.findings[0].raised_by.sort(), ['plan-critic', 'scope-guardian']); assert.equal(parsed.dedup_stats.total_out, 1); }); test('CLI --stdin tolerates a single present payload (other key absent)', () => { const stdin = JSON.stringify({ plan_critic: { agent: 'plan-critic', findings: [{ file: 'a.md', line: 1, rule_key: 'PC1', text: 'lonely finding' }] }, }); const out = execFileSync(process.execPath, [SHIM, '--stdin'], { input: stdin, encoding: 'utf-8' }); const parsed = JSON.parse(out); assert.equal(parsed.findings.length, 1); assert.deepEqual(parsed.findings[0].raised_by, ['plan-critic']); }); test('severity survives the dedup, and a merge keeps the more severe of the two (the plan gate reads it)', () => { const sources = [ { agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', severity: 'Minor', text: 'Step 4 lacks verifiable acceptance criteria' }] } }, { agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', severity: 'blocker', text: 'Step 4 lacks verifiable acceptance criteria' }] } }, ]; const r = dedupFindings(sources); assert.equal(r.findings.length, 1); assert.equal(r.findings[0].severity, 'blocker'); const lone = dedupFindings([{ agent: 'plan-critic', payload: { findings: [{ file: 'p', line: 1, rule_key: 'k', severity: 'MAJOR', text: 't' }] } }]); assert.equal(lone.findings[0].severity, 'major'); const unrated = dedupFindings([{ agent: 'plan-critic', payload: { findings: [{ file: 'p', line: 1, rule_key: 'k', text: 't' }] } }]); assert.equal(unrated.findings[0].severity, null, 'an unrated finding stays unrated here; the gate decides what that means'); }); test('sources says which reviewers delivered a findings block (null, misnamed or findings-less = absent)', () => { const stdin = JSON.stringify({ plan_critic: { agent: 'plan-critic', findings: [] }, scopeGuardian: { findings: [] } }); const parsed = JSON.parse(execFileSync(process.execPath, [SHIM, '--stdin'], { input: stdin, encoding: 'utf-8' })); assert.deepEqual(parsed.sources, [ { agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: false }, ]); const noFindings = dedupFindings([{ agent: 'plan-critic', payload: { agent: 'plan-critic' } }]); assert.deepEqual(noFindings.sources, [{ agent: 'plan-critic', present: false }]); }); test('CLI --stdin exits non-zero on malformed input (loud, not a silent empty merge)', () => { assert.throws( () => execFileSync(process.execPath, [SHIM, '--stdin'], { input: 'not json{', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }), /Command failed|status 1/, 'malformed --stdin must fail loudly so a broken hand-off cannot hide behind an empty merge', ); }); // --- Converging critic loop, Step 3 — the reviewers' marks and rule-B fields // pass through dedup, and two dedup defects are fixed (line-0 collapse, // a bad line / empty file / empty rule_key crashing the id) --- const HEX_A = 'a'.repeat(40); const HEX_B = '0123456789abcdef0123456789abcdef01234567'; const HEX_C = 'f'.repeat(40); function runStdin(obj) { return JSON.parse(execFileSync(process.execPath, [SHIM, '--stdin'], { input: JSON.stringify(obj), encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], })); } test('the new per-finding fields survive dedupFindings (line_end, lowered_from, caught_by, rule_keys)', () => { const r = dedupFindings([{ agent: 'plan-critic', payload: { findings: [ { file: 'plan.md', line: 3, line_end: 5, rule_key: 'PC1', severity: 'minor', lowered_from: 'Major', caught_by: 'tests/lib/x.test.mjs', text: 'first finding about the token check' }, ] } }]); const f = r.findings[0]; assert.equal(f.line_end, 5); assert.equal(f.lowered_from, 'major', 'lowered_from goes through normalizeSeverity'); assert.equal(f.caught_by, 'tests/lib/x.test.mjs'); assert.deepEqual(f.rule_keys, ['PC1']); assert.equal(f.severity, 'minor'); }); test('unusable per-finding fields are dropped: line_end below line or not an integer, empty caught_by, no rule_key', () => { const one = finding => dedupFindings([{ agent: 'plan-critic', payload: { findings: [finding] } }]).findings[0]; assert.equal('line_end' in one({ file: 'p', line: 3, line_end: 2, rule_key: 'k', text: 't' }), false, 'line_end < line'); assert.equal('line_end' in one({ file: 'p', line: 3, line_end: 4.5, rule_key: 'k', text: 't' }), false, 'line_end not an integer'); assert.equal('line_end' in one({ file: 'p', line: 3, line_end: '5', rule_key: 'k', text: 't' }), false, 'line_end a string'); assert.equal(one({ file: 'p', line: 3, line_end: 3, rule_key: 'k', text: 't' }).line_end, 3, 'line_end equal to line is kept'); assert.equal('caught_by' in one({ file: 'p', line: 3, rule_key: 'k', caught_by: '', text: 't' }), false, 'empty caught_by'); assert.equal('caught_by' in one({ file: 'p', line: 3, rule_key: 'k', caught_by: 7, text: 't' }), false, 'caught_by not a string'); assert.equal('lowered_from' in one({ file: 'p', line: 3, rule_key: 'k', text: 't' }), false, 'no lowered_from in, none out'); assert.equal('rule_keys' in one({ file: 'p', line: 3, text: 't' }), false, 'no rule_key, no rule_keys'); }); test('CLI --stdin passes marks per reviewer and the new fields through; sources stays {agent, present}', () => { const parsed = runStdin({ plan_critic: { agent: 'plan-critic', previous: [{ id: HEX_A, resolved: true }, { id: HEX_B, resolved: false, note: 'extra field' }], findings: [{ file: 'plan.md', line: 8, line_end: 9, rule_key: 'PC1', severity: 'minor', lowered_from: 'major', caught_by: 'npm test', text: 'alpha finding about the snapshot path' }], }, scope_guardian: { agent: 'scope-guardian', previous: [{ id: HEX_A, resolved: false }], findings: [], }, }); assert.deepEqual(parsed.marks, [ { agent: 'plan-critic', id: HEX_A, resolved: true }, { agent: 'plan-critic', id: HEX_B, resolved: false }, { agent: 'scope-guardian', id: HEX_A, resolved: false }, ]); assert.deepEqual(parsed.sources, [ { agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }, ]); const f = parsed.findings[0]; assert.equal(f.line_end, 9); assert.equal(f.lowered_from, 'major'); assert.equal(f.caught_by, 'npm test'); assert.deepEqual(f.rule_keys, ['PC1']); }); test('a malformed mark is dropped, never thrown on; marks keep input order; no previous gives []', () => { const r = dedupFindings([ { agent: 'plan-critic', payload: { agent: 'plan-critic', findings: [], previous: [ null, 'not-an-object', { id: 'A'.repeat(40), resolved: true }, // upper-case hex { id: 'a'.repeat(39), resolved: true }, // 39 hex { id: HEX_A, resolved: 'true' }, // resolved not boolean { id: HEX_A, resolved: 1 }, { id: 123, resolved: true }, // id not a string { resolved: true }, // no id { id: HEX_C, resolved: false }, { id: HEX_B, resolved: true }, ] } }, { agent: 'scope-guardian', payload: { findings: [], previous: 'not-an-array' } }, { agent: 'third', payload: { findings: [] } }, ]); assert.deepEqual(r.marks, [ { agent: 'plan-critic', id: HEX_C, resolved: false }, { agent: 'plan-critic', id: HEX_B, resolved: true }, ]); assert.deepEqual(r.sources, [ { agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }, { agent: 'third', present: true }, ]); const none = dedupFindings([{ agent: 'plan-critic', payload: null }]); assert.deepEqual(none.marks, []); }); test('a merge takes the union of rule_keys: a security-keyed finding merged into a differently-keyed survivor keeps its key', () => { const text = 'the write path builds a shell command from the plan text without quoting'; const r = dedupFindings([ { agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 10, rule_key: 'PLAN_EXECUTE_DRIFT', severity: 'major', text }] } }, { agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 30, rule_key: 'SECURITY_INJECTION', severity: 'major', text }] } }, ]); assert.equal(r.findings.length, 1); assert.equal(r.findings[0].rule_key, 'PLAN_EXECUTE_DRIFT', 'the survivor keeps its own rule_key'); assert.deepEqual(r.findings[0].rule_keys, ['PLAN_EXECUTE_DRIFT', 'SECURITY_INJECTION']); const same = dedupFindings([ { agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 10, rule_key: 'PC1', text }] } }, { agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 10, rule_key: 'PC1', text }] } }, ]); assert.deepEqual(same.findings[0].rule_keys, ['PC1'], 'a key is held once'); }); test('a merge with a more severe partner takes its severity and its lowered_from/caught_by, or drops them', () => { const at = (severity, extra = {}) => ({ file: 'plan.md', line: 42, rule_key: 'PC1', severity, text: 'step four lacks a verifiable acceptance criterion', ...extra }); // Partner more severe, partner has no rule-B fields: the survivor's are dropped. const dropped = dedupFindings([ { agent: 'plan-critic', payload: { findings: [at('minor', { lowered_from: 'major', caught_by: 'test a' })] } }, { agent: 'scope-guardian', payload: { findings: [at('blocker')] } }, ]).findings; assert.equal(dropped.length, 1); assert.equal(dropped[0].severity, 'blocker'); assert.equal('lowered_from' in dropped[0], false); assert.equal('caught_by' in dropped[0], false); // Partner more severe with its own rule-B fields: they are taken. const taken = dedupFindings([ { agent: 'plan-critic', payload: { findings: [at('minor')] } }, { agent: 'scope-guardian', payload: { findings: [at('major', { lowered_from: 'blocker', caught_by: 'gate b' })] } }, ]).findings[0]; assert.equal(taken.severity, 'major'); assert.equal(taken.lowered_from, 'blocker'); assert.equal(taken.caught_by, 'gate b'); // Equal severities: the survivor keeps its own. const kept = dedupFindings([ { agent: 'plan-critic', payload: { findings: [at('minor', { lowered_from: 'major', caught_by: 'test a' })] } }, { agent: 'scope-guardian', payload: { findings: [at('minor', { lowered_from: 'major', caught_by: 'test b' })] } }, ]).findings[0]; assert.equal(kept.caught_by, 'test a'); const keptAlone = dedupFindings([ { agent: 'plan-critic', payload: { findings: [at('minor', { lowered_from: 'major', caught_by: 'test a' })] } }, { agent: 'scope-guardian', payload: { findings: [at('minor')] } }, ]).findings[0]; assert.equal(keptAlone.lowered_from, 'major'); assert.equal(keptAlone.caught_by, 'test a'); }); test('two line-0 findings with one rule_key and different texts stay two, with distinct ids', () => { const r = dedupFindings([{ agent: 'plan-critic', payload: { findings: [ { file: 'plan.md', line: 0, rule_key: 'PC1', text: 'the plan never names a rollback for the migration' }, { file: 'plan.md', line: 0, rule_key: 'PC1', text: 'no step owns the changelog entry for this release' }, { file: 'plan.md', line: 0, rule_key: 'PC1', text: 'the brief success criterion four has no command' }, ] } }]); assert.equal(r.findings.length, 3); assert.equal(r.dedup_stats.exact_id_dups, 0); assert.deepEqual(r.findings.map(f => f.id), [ computeFindingId('plan.md', 0, 'PC1'), computeFindingId('plan.md', 0, 'PC1#2'), computeFindingId('plan.md', 0, 'PC1#3'), ]); // The same line-0 finding from two reviewers still merges (through the text). const same = dedupFindings([ { agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 0, rule_key: 'PC1', text: 'the plan never names a rollback for the migration' }] } }, { agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 0, rule_key: 'PC1', text: 'the plan never names a rollback for the migration' }] } }, ]); assert.equal(same.findings.length, 1); assert.deepEqual(same.findings[0].raised_by, ['plan-critic', 'scope-guardian']); }); test('a line that is not a finite integer becomes null; an empty file or rule_key becomes null; nothing throws', () => { const one = finding => dedupFindings([{ agent: 'plan-critic', payload: { findings: [finding] } }]).findings[0]; for (const line of [[3, 5], '7', 2.5, Infinity, NaN, { n: 1 }]) { const f = one({ file: 'plan.md', line, rule_key: 'PC1', text: 't' }); assert.equal(f.line, null, `line ${JSON.stringify(line)} → null`); assert.equal(f.id, computeFindingId('plan.md', 0, 'PC1')); } const noFile = one({ file: '', line: 4, rule_key: 'PC2', text: 't' }); assert.equal(noFile.file, null); assert.equal(noFile.id, computeFindingId('unknown', 4, 'PC2')); const noKey = one({ file: 'plan.md', line: 7, rule_key: '', text: 't' }); assert.equal(noKey.rule_key, null); assert.equal('rule_keys' in noKey, false); assert.equal(noKey.id, computeFindingId('plan.md', 7, 'unknown')); }); test('CLI --stdin: line [3, 5], file "" and rule_key "" no longer crash it (exit 0, line null)', () => { const parsed = runStdin({ plan_critic: { agent: 'plan-critic', findings: [ { file: 'plan.md', line: [3, 5], rule_key: 'PC1', text: 'a finding cited with a line range array' }, { file: '', line: 4, rule_key: 'PC2', text: 'a finding with an empty file string' }, { file: 'plan.md', line: 7, rule_key: '', text: 'a finding with an empty rule key here' }, ] }, }); assert.equal(parsed.findings.length, 3); assert.equal(parsed.findings[0].line, null); assert.equal(parsed.findings[1].file, null); assert.equal(parsed.findings[2].rule_key, null); }); test('one reviewer, one file, line [3, 5], one rule_key, different texts: two findings with distinct ids, CLI exit 0', () => { const parsed = runStdin({ plan_critic: { agent: 'plan-critic', findings: [ { file: 'plan.md', line: [3, 5], rule_key: 'PC1', text: 'the snapshot directory is never created before the write' }, { file: 'plan.md', line: [3, 5], rule_key: 'PC1', text: 'verify command runs the whole suite instead of one file' }, ] }, }); assert.equal(parsed.findings.length, 2); assert.deepEqual(parsed.findings.map(f => f.id), [ computeFindingId('plan.md', 0, 'PC1'), computeFindingId('plan.md', 0, 'PC1#2'), ]); assert.deepEqual(parsed.findings.map(f => f.line), [null, null]); }); test('contentOverlap is exported and gives 1 for a shorter text held by a longer one', () => { assert.equal(typeof dedupModule.contentOverlap, 'function', 'contentOverlap is exported'); assert.equal(dedupModule.contentOverlap('The token is checked.', 'The token is not checked before the write.'), 1); assert.equal(dedupModule.contentOverlap('alpha beta', 'alpha gamma'), 0.5); assert.equal(dedupModule.contentOverlap('', 'alpha'), 0); });