voyage/lib/review/plan-review-dedup.mjs
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.

- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
  text says so (red-proof, trekexecute Sub-step B, step-implementer,
  HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
  already in the tree when the step began no longer counts against it, and a
  plan placed outside .claude/ reads the same. The round-6 test that had the
  diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
  (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
  the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
  not "could not start"; the release check tells a timeout from output past
  its buffer and from another signal. One reading of a stop
  (criteria-runner stoppedBy) for red proof, the criteria runner and the
  release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
  command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
  the realpath guard and the 20 modules still on the file:// guard, which
  exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
  three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
  passed at once; it fails when 127 is read as could-not-run); the prose now
  names the git revision `@~1` as the one leading-@ exception.

Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:11:57 +02:00

298 lines
11 KiB
JavaScript

// lib/review/plan-review-dedup.mjs
// Phase-9 dedup helper for /trekplan adversarial review:
// merges plan-critic + scope-guardian findings into a single deduplicated
// stream, preserving provenance (which agent originally raised each finding).
//
// Three dedup signals:
// 1. Exact match — identical computeFindingId(file:line:rule_key) → merge.
// 2. Jaccard ≥ 0.7 on text-token sets → merge (catches near-duplicates).
// 3. Same anchor — the same file and the same line (≥ 1), and the content
// words of the shorter text mostly in the longer one (overlap ≥ the same
// 0.7) → merge. Two reviewers naming one defect at one place word it
// differently and pick their own rule_key: trial 2's pair on plan.md:248
// measured Jaccard 0.44 and stayed two (voyage-lekerepo, 2026-10-08).
// Only an anchored pair from two different reviewers, the shorter text
// holding ≥ 6 content words, is measured this way; the threshold for
// every other pair is unchanged.
//
// Provenance is preserved on the surviving finding's `raised_by` array.
// Severity is kept (normalized to blocker | major | minor, else null) and a
// merge keeps the more severe of the two: the plan gate (K3,
// lib/validators/plan-gate.mjs) stops on a BLOCKER, so a merge must never
// downgrade one.
//
// CLI shim — two input modes:
// file mode: node lib/review/plan-review-dedup.mjs \
// --plan-critic /tmp/x.json --scope-guardian /tmp/y.json
// stdin mode: node lib/review/plan-review-dedup.mjs --stdin (reads fd 0:
// one object {plan_critic, scope_guardian} of agent payloads)
// → stdout: deduped JSON, exit 0 on success.
//
// File mode tolerates empty / missing inputs (single-agent review still works).
// stdin mode is the Phase-9 path: the read-only reviewers (plan-critic,
// scope-guardian) cannot write temp files, so /trekplan pipes their inline JSON
// blocks here. Malformed stdin exits NON-ZERO — a broken hand-off must surface
// loudly, never collapse into a silent empty merge.
import { readFileSync } from 'node:fs';
import { jaccardSimilarity, meetsThreshold } from '../parsers/jaccard.mjs';
import { computeFindingId } from '../parsers/finding-id.mjs';
import { isMain } from '../util/is-main.mjs';
export const DEFAULT_THRESHOLD = 0.7;
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor']);
/** 'Blocker' / 'MAJOR' → canonical lowercase; anything else → null (unrated). */
export function normalizeSeverity(s) {
const v = typeof s === 'string' ? s.trim().toLowerCase() : '';
return SEVERITIES.includes(v) ? v : null;
}
// Lower rank = more severe; unrated ranks with blocker so a merge never hides it.
const rank = s => (s === null ? 0 : SEVERITIES.indexOf(s));
function mergeInto(survivor, other) {
for (const a of other.raised_by) {
if (!survivor.raised_by.includes(a)) survivor.raised_by.push(a);
}
if (rank(other.severity) < rank(survivor.severity)) survivor.severity = other.severity;
}
/**
* Tokenize a finding's text for Jaccard comparison: lowercase, split on
* non-word, drop empties. Stable + deterministic.
*/
export function tokenize(text) {
if (typeof text !== 'string' || text.length === 0) return [];
return text.toLowerCase().split(/\W+/).filter(t => t.length > 0);
}
// Words that carry no content of a finding.
const STOP_WORDS = new Set([
'a', 'an', 'the', 'and', 'or', 'but', 'of', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'from', 'as',
'is', 'are', 'was', 'were', 'be', 'been', 'it', 'its', 'this', 'that', 'these', 'those', 'only', 'both',
'where', 'which', 'while', 'when', 'than', 'then', 'so', 'if', 'also', 'each', 'every', 'all', 'any',
'unlike', 'own',
]);
/** Content words of a finding: tokens minus stop words and single letters, a plural -s folded. */
export function contentTokens(text) {
return tokenize(text)
.filter(t => t.length > 1 && !STOP_WORDS.has(t))
.map(t => (t.length > 3 && t.endsWith('s') && !t.endsWith('ss') ? t.slice(0, -1) : t));
}
/** |A ∩ B| / min(|A|, |B|): how much of the shorter text the longer one holds. */
function overlap(a, b) {
const A = new Set(a);
const B = new Set(b);
if (A.size === 0 || B.size === 0) return 0;
let n = 0;
for (const x of A) if (B.has(x)) n += 1;
return n / Math.min(A.size, B.size);
}
/** Same file and same line ≥ 1: line 0 (file-scoped) and a missing line anchor nothing. */
function sameAnchor(a, b) {
return typeof a.file === 'string' && a.file !== '' && a.file === b.file &&
Number.isInteger(a.line) && a.line > 0 && a.line === b.line;
}
// Overlap measures the shorter text against the longer, so a short text is
// held by almost any longer one on its line ("The token is checked." inside
// "The token is not checked before the write." scores 1.0). The anchor rule
// therefore merges only a pair from two different reviewers — one reviewer's
// two findings on one line are two findings by its own count — and only when
// the shorter text names enough to be compared (independent review, 2026-10-08).
export const ANCHOR_MIN_CONTENT_WORDS = 6;
function anchorDuplicate(f, s, threshold) {
if (!sameAnchor(f, s)) return false;
if (f.raised_by.some(a => s.raised_by.includes(a))) return false;
const a = new Set(contentTokens(f.text));
const b = new Set(contentTokens(s.text));
if (Math.min(a.size, b.size) < ANCHOR_MIN_CONTENT_WORDS) return false;
return meetsThreshold(overlap([...a], [...b]), threshold);
}
/**
* Normalize a single agent payload into an array of {agent, finding} pairs.
* Tolerates missing payload (returns []).
*/
function normalizeAgentPayload(payload, fallbackAgent) {
if (!payload || typeof payload !== 'object') return [];
const agent = (typeof payload.agent === 'string' && payload.agent.length > 0)
? payload.agent
: fallbackAgent;
const findings = Array.isArray(payload.findings) ? payload.findings : [];
return findings.map(f => ({ agent, finding: f }));
}
function annotate(finding, agent) {
const id = computeFindingId(
String(finding.file ?? 'unknown'),
finding.line ?? 0,
String(finding.rule_key ?? 'unknown'),
);
return {
id,
file: finding.file ?? null,
line: finding.line ?? null,
rule_key: finding.rule_key ?? null,
severity: normalizeSeverity(finding.severity),
text: typeof finding.text === 'string' ? finding.text : '',
raised_by: [agent],
};
}
/**
* Dedup an arbitrary collection of agent payloads.
*
* @param {Array<{agent: string, payload: object | null | undefined}>} sources
* @param {{ threshold?: number }} [opts]
* @returns {{
* findings: Array<object>,
* sources: Array<{agent: string, present: boolean}>,
* dedup_stats: { total_in: number, total_out: number,
* exact_id_dups: number, jaccard_dups: number, anchor_dups: number }
* }}
*/
export function dedupFindings(sources, opts = {}) {
const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_THRESHOLD;
const incoming = [];
for (const s of sources) {
for (const pair of normalizeAgentPayload(s.payload, s.agent)) {
incoming.push(annotate(pair.finding, pair.agent));
}
}
const total_in = incoming.length;
// Pass 1 — exact id dedup
const byId = new Map();
let exact_id_dups = 0;
for (const f of incoming) {
const existing = byId.get(f.id);
if (existing) {
mergeInto(existing, f);
exact_id_dups += 1;
} else {
byId.set(f.id, f);
}
}
// Pass 2 — jaccard on text tokens; merge near-duplicates. Pass 3 rides in
// the same loop: an anchored pair that Jaccard misses is measured by overlap.
const survivors = [];
let jaccard_dups = 0;
let anchor_dups = 0;
for (const f of byId.values()) {
const tokens = tokenize(f.text);
let merged = false;
for (const s of survivors) {
const sim = jaccardSimilarity(tokens, tokenize(s.text));
if (meetsThreshold(sim, threshold)) {
mergeInto(s, f);
jaccard_dups += 1;
merged = true;
break;
}
if (anchorDuplicate(f, s, threshold)) {
mergeInto(s, f);
anchor_dups += 1;
merged = true;
break;
}
}
if (!merged) survivors.push(f);
}
return {
findings: survivors,
// Which reviewers actually delivered a findings block. `findings: []` from
// a reviewer that was absent (null payload, misspelled key, no `findings`)
// is not a clean review; plan-gate --record refuses it.
sources: sources.map(s => ({
agent: s.agent,
present: Boolean(s.payload && typeof s.payload === 'object' && Array.isArray(s.payload.findings)),
})),
dedup_stats: {
total_in,
total_out: survivors.length,
exact_id_dups,
jaccard_dups,
anchor_dups,
},
};
}
// ---- CLI shim ----------------------------------------------------------------
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--plan-critic') out.planCritic = argv[++i];
else if (a === '--scope-guardian') out.scopeGuardian = argv[++i];
else if (a === '--threshold') out.threshold = Number(argv[++i]);
else if (a === '--stdin') out.stdin = true;
}
return out;
}
function readJsonOrNull(path) {
if (!path) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8'));
} catch {
return null;
}
}
// --stdin mode reads ONE object {plan_critic, scope_guardian} from fd 0. Unlike
// the file path mode (which tolerates absent files so single-agent review still
// works), malformed stdin is a hard error: --stdin means the caller intended to
// pipe both inline review blocks, so a parse failure is a broken hand-off that
// must surface loudly — not collapse into a silent empty merge (the original
// Phase-9 defect: read-only reviewers never wrote the temp files, the helper
// swallowed the absence, and the dedup ran on nothing).
function readStdinSourcesOrExit() {
let raw;
try {
raw = readFileSync(0, 'utf-8');
} catch (err) {
process.stderr.write(`plan-review-dedup --stdin: cannot read stdin: ${err.message}\n`);
process.exit(1);
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
process.stderr.write(`plan-review-dedup --stdin: malformed JSON on stdin: ${err.message}\n`);
process.exit(1);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
process.stderr.write('plan-review-dedup --stdin: expected an object {plan_critic, scope_guardian}\n');
process.exit(1);
}
return [
{ agent: 'plan-critic', payload: parsed.plan_critic ?? null },
{ agent: 'scope-guardian', payload: parsed.scope_guardian ?? null },
];
}
if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
const sources = args.stdin
? readStdinSourcesOrExit()
: [
{ agent: 'plan-critic', payload: readJsonOrNull(args.planCritic) },
{ agent: 'scope-guardian', payload: readJsonOrNull(args.scopeGuardian) },
];
const opts = {};
if (Number.isFinite(args.threshold)) opts.threshold = args.threshold;
const result = dedupFindings(sources, opts);
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
process.exit(0);
}