fix(review): plan dedup passes the reviewers' marks and rule-B fields through and keeps distinct file-level findings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3a0c2d0c54
commit
31fa6f8e43
2 changed files with 322 additions and 21 deletions
|
|
@ -15,6 +15,21 @@
|
|||
// holding ≥ 6 content words, is measured this way; the threshold for
|
||||
// every other pair is unchanged.
|
||||
//
|
||||
// Pass-through for the converging critic loop (2026-10-15). A reviewer's
|
||||
// top-level `previous` marks ({id: 40-hex, resolved: boolean}) come out as
|
||||
// top-level `marks: [{agent, id, resolved}]` in input order; a malformed mark
|
||||
// is dropped, never thrown on, and `sources` keeps exactly {agent, present}.
|
||||
// A finding keeps an integer `line_end` ≥ `line`, rule B's `lowered_from`
|
||||
// (normalized) and a non-empty `caught_by`, and carries `rule_keys`: the union
|
||||
// of the keys merged into it, so a security key survives a merge into a
|
||||
// differently-keyed survivor. Two defects are fixed. A `line` that is not a
|
||||
// finite integer becomes null and an empty `file` or `rule_key` becomes null,
|
||||
// with 'unknown' / 0 given to computeFindingId, which threw on them and took
|
||||
// the CLI down. And pass 1 merges equal ids only on a line ≥ 1: file-level
|
||||
// findings (line 0 or none) of one rule_key no longer collapse into one; they
|
||||
// go through the text passes, and a second survivor with a taken id gets
|
||||
// `<rule_key>#n` (n = 2, 3, …) in its id.
|
||||
//
|
||||
// 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,
|
||||
|
|
@ -55,7 +70,18 @@ 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;
|
||||
const keys = [...(survivor.rule_keys ?? [])];
|
||||
for (const k of other.rule_keys ?? []) if (!keys.includes(k)) keys.push(k);
|
||||
if (keys.length > 0) survivor.rule_keys = keys;
|
||||
// The more severe partner brings its rule-B record with it (or none); on
|
||||
// equal severity the survivor keeps its own.
|
||||
if (rank(other.severity) < rank(survivor.severity)) {
|
||||
survivor.severity = other.severity;
|
||||
for (const field of ['lowered_from', 'caught_by']) {
|
||||
if (field in other) survivor[field] = other[field];
|
||||
else delete survivor[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,6 +118,11 @@ function overlap(a, b) {
|
|||
return n / Math.min(A.size, B.size);
|
||||
}
|
||||
|
||||
/** Content-word overlap of two texts: how much of the shorter one the longer holds (0..1). */
|
||||
export function contentOverlap(a, b) {
|
||||
return overlap(contentTokens(a), contentTokens(b));
|
||||
}
|
||||
|
||||
/** 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 &&
|
||||
|
|
@ -116,33 +147,57 @@ function anchorDuplicate(f, s, threshold) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Normalize a single agent payload into an array of {agent, finding} pairs.
|
||||
* Tolerates missing payload (returns []).
|
||||
* Normalize a single agent payload into its {agent, finding} pairs and its
|
||||
* `previous` array. Tolerates missing payload (no pairs, no previous).
|
||||
*/
|
||||
function normalizeAgentPayload(payload, fallbackAgent) {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
if (!payload || typeof payload !== 'object') return { agent: fallbackAgent, pairs: [], previous: [] };
|
||||
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 }));
|
||||
const previous = Array.isArray(payload.previous) ? payload.previous : [];
|
||||
return { agent, pairs: findings.map(f => ({ agent, finding: f })), previous };
|
||||
}
|
||||
|
||||
const FINDING_ID_RE = /^[0-9a-f]{40}$/;
|
||||
|
||||
/** A reviewer's mark on a previous finding, or null when it is not {id: 40-hex, resolved: boolean}. */
|
||||
function readMark(m, agent) {
|
||||
if (!m || typeof m !== 'object') return null;
|
||||
if (typeof m.id !== 'string' || !FINDING_ID_RE.test(m.id)) return null;
|
||||
if (typeof m.resolved !== 'boolean') return null;
|
||||
return { agent, id: m.id, resolved: m.resolved };
|
||||
}
|
||||
|
||||
// The substitutes computeFindingId gets for a null file, line or rule_key.
|
||||
const idOf = (file, line, ruleKey) =>
|
||||
computeFindingId(String(file ?? 'unknown'), line ?? 0, String(ruleKey ?? 'unknown'));
|
||||
|
||||
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,
|
||||
const file = finding.file === '' ? null : (finding.file ?? null);
|
||||
const line = Number.isInteger(finding.line) ? finding.line : null;
|
||||
const ruleKey = finding.rule_key === '' ? null : (finding.rule_key ?? null);
|
||||
const out = {
|
||||
id: idOf(file, line, ruleKey),
|
||||
file,
|
||||
line,
|
||||
rule_key: ruleKey,
|
||||
severity: normalizeSeverity(finding.severity),
|
||||
text: typeof finding.text === 'string' ? finding.text : '',
|
||||
raised_by: [agent],
|
||||
};
|
||||
if (line !== null && Number.isInteger(finding.line_end) && finding.line_end >= line) {
|
||||
out.line_end = finding.line_end;
|
||||
}
|
||||
if (finding.lowered_from !== undefined && finding.lowered_from !== null) {
|
||||
out.lowered_from = normalizeSeverity(finding.lowered_from);
|
||||
}
|
||||
if (typeof finding.caught_by === 'string' && finding.caught_by.trim() !== '') {
|
||||
out.caught_by = finding.caught_by;
|
||||
}
|
||||
if (typeof ruleKey === 'string') out.rule_keys = [ruleKey];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -152,6 +207,7 @@ function annotate(finding, agent) {
|
|||
* @param {{ threshold?: number }} [opts]
|
||||
* @returns {{
|
||||
* findings: Array<object>,
|
||||
* marks: Array<{agent: string, id: string, resolved: boolean}>,
|
||||
* sources: Array<{agent: string, present: boolean}>,
|
||||
* dedup_stats: { total_in: number, total_out: number,
|
||||
* exact_id_dups: number, jaccard_dups: number, anchor_dups: number }
|
||||
|
|
@ -161,24 +217,33 @@ export function dedupFindings(sources, opts = {}) {
|
|||
const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_THRESHOLD;
|
||||
|
||||
const incoming = [];
|
||||
const marks = [];
|
||||
for (const s of sources) {
|
||||
for (const pair of normalizeAgentPayload(s.payload, s.agent)) {
|
||||
incoming.push(annotate(pair.finding, pair.agent));
|
||||
const { agent, pairs, previous } = normalizeAgentPayload(s.payload, s.agent);
|
||||
for (const pair of pairs) incoming.push(annotate(pair.finding, pair.agent));
|
||||
for (const m of previous) {
|
||||
const mark = readMark(m, agent);
|
||||
if (mark) marks.push(mark);
|
||||
}
|
||||
}
|
||||
|
||||
const total_in = incoming.length;
|
||||
|
||||
// Pass 1 — exact id dedup
|
||||
// Pass 1 — exact id dedup, only on a line ≥ 1. A file-level finding (line 0
|
||||
// or none) shares its id with every other one of its rule_key, so equal ids
|
||||
// there say nothing: those go on to the text passes.
|
||||
const byId = new Map();
|
||||
const pass2 = [];
|
||||
let exact_id_dups = 0;
|
||||
for (const f of incoming) {
|
||||
const existing = byId.get(f.id);
|
||||
const anchored = Number.isInteger(f.line) && f.line > 0;
|
||||
const existing = anchored ? byId.get(f.id) : undefined;
|
||||
if (existing) {
|
||||
mergeInto(existing, f);
|
||||
exact_id_dups += 1;
|
||||
} else {
|
||||
byId.set(f.id, f);
|
||||
if (anchored) byId.set(f.id, f);
|
||||
pass2.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +252,7 @@ export function dedupFindings(sources, opts = {}) {
|
|||
const survivors = [];
|
||||
let jaccard_dups = 0;
|
||||
let anchor_dups = 0;
|
||||
for (const f of byId.values()) {
|
||||
for (const f of pass2) {
|
||||
const tokens = tokenize(f.text);
|
||||
let merged = false;
|
||||
for (const s of survivors) {
|
||||
|
|
@ -208,8 +273,20 @@ export function dedupFindings(sources, opts = {}) {
|
|||
if (!merged) survivors.push(f);
|
||||
}
|
||||
|
||||
// A second survivor with an id already taken gets `<rule_key>#n` in its id.
|
||||
const taken = new Set();
|
||||
for (const f of survivors) {
|
||||
if (taken.has(f.id)) {
|
||||
let n = 2;
|
||||
while (taken.has(idOf(f.file, f.line, `${f.rule_key ?? 'unknown'}#${n}`))) n += 1;
|
||||
f.id = idOf(f.file, f.line, `${f.rule_key ?? 'unknown'}#${n}`);
|
||||
}
|
||||
taken.add(f.id);
|
||||
}
|
||||
|
||||
return {
|
||||
findings: survivors,
|
||||
marks,
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ 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');
|
||||
|
|
@ -196,3 +200,223 @@ test('CLI --stdin exits non-zero on malformed input (loud, not a silent empty me
|
|||
'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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue