merge: wave 1 order 3 (plan and design) into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
885d515c3b
24 changed files with 3115 additions and 61 deletions
|
|
@ -8,6 +8,10 @@
|
|||
// 2. Jaccard ≥ 0.7 on text-token sets → merge (catches near-duplicates).
|
||||
//
|
||||
// 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 \
|
||||
|
|
@ -22,11 +26,29 @@
|
|||
// 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 { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { jaccardSimilarity, meetsThreshold } from '../parsers/jaccard.mjs';
|
||||
import { computeFindingId } from '../parsers/finding-id.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
|
||||
|
|
@ -61,6 +83,7 @@ function annotate(finding, agent) {
|
|||
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],
|
||||
};
|
||||
|
|
@ -73,6 +96,7 @@ function annotate(finding, agent) {
|
|||
* @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 }
|
||||
* }}
|
||||
|
|
@ -95,9 +119,7 @@ export function dedupFindings(sources, opts = {}) {
|
|||
for (const f of incoming) {
|
||||
const existing = byId.get(f.id);
|
||||
if (existing) {
|
||||
for (const a of f.raised_by) {
|
||||
if (!existing.raised_by.includes(a)) existing.raised_by.push(a);
|
||||
}
|
||||
mergeInto(existing, f);
|
||||
exact_id_dups += 1;
|
||||
} else {
|
||||
byId.set(f.id, f);
|
||||
|
|
@ -113,9 +135,7 @@ export function dedupFindings(sources, opts = {}) {
|
|||
for (const s of survivors) {
|
||||
const sim = jaccardSimilarity(tokens, tokenize(s.text));
|
||||
if (meetsThreshold(sim, threshold)) {
|
||||
for (const a of f.raised_by) {
|
||||
if (!s.raised_by.includes(a)) s.raised_by.push(a);
|
||||
}
|
||||
mergeInto(s, f);
|
||||
jaccard_dups += 1;
|
||||
merged = true;
|
||||
break;
|
||||
|
|
@ -126,6 +146,13 @@ export function dedupFindings(sources, opts = {}) {
|
|||
|
||||
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,
|
||||
|
|
@ -190,7 +217,17 @@ function readStdinSourcesOrExit() {
|
|||
];
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// realpath on both sides: `file://${argv[1]}` silently no-ops under a symlink
|
||||
// or a path with a space.
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const sources = args.stdin
|
||||
? readStdinSourcesOrExit()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue