484 lines
20 KiB
JavaScript
484 lines
20 KiB
JavaScript
// lib/review/critic-diff.mjs
|
|
// Rule A and the rule-B record for the plan gate (decision 43).
|
|
//
|
|
// Why: from round 2 on, the critic loop counts a new MAJOR that cites plan text
|
|
// outside the diff since the last reviewed round as minor (rule A), so a loop of
|
|
// critic rounds converges on what changed instead of re-litigating text that was
|
|
// already reviewed. That needs a line diff between two operative plan texts
|
|
// that is deterministic: the same two texts always give the same hunks, so the
|
|
// same findings always give the same verdict.
|
|
//
|
|
// lineDiff(oldText, newText) -> { inside, oldToNew, hunks }
|
|
// Lines are 1-based operative lines; CRLF is normalized first.
|
|
// inside Set of every inserted or changed new line; for a pure-deletion
|
|
// hunk also the new lines on both sides of the deletion point.
|
|
// oldToNew oldToNew[o] = the new line an unchanged old line moved to, else
|
|
// null (index 0 is null).
|
|
// hunks [{oldStart, oldEnd, newStart, newEnd}], inclusive ranges in order;
|
|
// an empty side has end = start - 1.
|
|
// How: trim the common prefix and suffix; anchor on lines that occur exactly
|
|
// once in both texts, keep the longest increasing subsequence of anchors and
|
|
// recurse between them; a gap with no unique line uses an LCS table, ties
|
|
// resolved toward matching the earliest old line.
|
|
//
|
|
// countRound({round, previous, base, baseNote, current, planPaths, findings, marks})
|
|
// -> { findings, resolved, diffBase, diffNote, lowered }
|
|
// Decides each finding's counted severity for one critic round (RULE_A,
|
|
// RULE_B below). `current` = {text, sha256, fullToOp, opToFull}, built by the
|
|
// caller from the plan gate's operativeLines and planHash; `base` = {sha256,
|
|
// text} is the operative text the previous round reviewed, or null.
|
|
// Rule A is on only from round 2, with a base whose sha256 is the previous
|
|
// verdict's plan_sha256 and differs from the plan now. Otherwise the round is
|
|
// counted as before (each finding at its reported severity, plus the rule-B
|
|
// record) and `diffNote` says why in one line. With rule A on, a current
|
|
// finding outside the diff is matched to a previous one (same id, its mapped
|
|
// line, or the same wording in the same file class); a previous finding stays
|
|
// counted until every reviewer that raised it marks it resolved, no reviewer
|
|
// says it is not, and nobody re-reports it.
|
|
// Carried findings come first (previous order), then new ones (dedup order).
|
|
//
|
|
// Trust limit: anything that can write the plan can rewrite the previous
|
|
// verdict's plan_sha256 and plant a matching base snapshot. That was already
|
|
// the stated limit of the verdict block; rule A raises what such a forgery
|
|
// buys. An unchanged base lowers nothing.
|
|
//
|
|
// Pure: no I/O, and it never reads the plan gate (one dependency direction).
|
|
//
|
|
// Credit: patience-style anchoring (unique-line anchors kept by their longest
|
|
// increasing subsequence), the idea behind Bram Cohen's patience diff; the code
|
|
// here is written fresh.
|
|
|
|
import { resolve } from 'node:path';
|
|
import {
|
|
normalizeSeverity, contentOverlap, contentTokens, DEFAULT_THRESHOLD, ANCHOR_MIN_CONTENT_WORDS,
|
|
} from './plan-review-dedup.mjs';
|
|
import { getRule } from './rule-catalogue.mjs';
|
|
|
|
export const RULE_A = 'Rule A: from round 2 on, a new major finding that cites a plan line outside the diff since the last reviewed text counts as minor; a blocker, a security finding and a finding with no plan line count at full severity, and a previous finding stays counted until it is marked resolved.';
|
|
export const RULE_B = 'Rule B: a major that a named test, `Regression:` command or gate in the plan will fail on during execution is minor — never a blocker, never a security finding.';
|
|
|
|
// The optional voyage.plan-verdict/1 fields of decision 43; absent means round-1 behaviour.
|
|
export const DIFF_FIELDS = Object.freeze([
|
|
// top level
|
|
'diff_base', 'diff_note', 'resolved',
|
|
// per finding
|
|
'op_line', 'op_line_end', 'line_end', 'rule_keys', 'original_severity', 'lowered_by', 'caught_by',
|
|
'lowering_refused', 'carried_from',
|
|
]);
|
|
|
|
/** A rule_key whose catalogue entry is in the security category (case and spaces ignored). */
|
|
export function isSecurityKey(key) {
|
|
return getRule(String(key).trim().toUpperCase())?.category === 'security';
|
|
}
|
|
|
|
const toLines = (text) => {
|
|
const t = String(text ?? '').replace(/\r\n?/g, '\n');
|
|
if (t === '') return [];
|
|
const out = t.split('\n');
|
|
if (out[out.length - 1] === '') out.pop();
|
|
return out;
|
|
};
|
|
|
|
/** Indexes (into `items`) of a longest strictly increasing subsequence of items[k].b; earliest on ties. */
|
|
function lisByB(items) {
|
|
const tails = []; // tails[len-1] = index into items of the smallest tail
|
|
const prev = new Array(items.length).fill(-1);
|
|
for (let k = 0; k < items.length; k++) {
|
|
const v = items[k].b;
|
|
let lo = 0;
|
|
let hi = tails.length;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (items[tails[mid]].b < v) lo = mid + 1; else hi = mid;
|
|
}
|
|
if (lo > 0) prev[k] = tails[lo - 1];
|
|
tails[lo] = k;
|
|
}
|
|
const out = [];
|
|
for (let k = tails.length ? tails[tails.length - 1] : -1; k !== -1; k = prev[k]) out.push(k);
|
|
return out.reverse();
|
|
}
|
|
|
|
/** LCS over a[aLo..aHi) x b[bLo..bHi); pushes matched [i, j] pairs in order. */
|
|
function lcsGap(a, aLo, aHi, b, bLo, bHi, pairs) {
|
|
const n = aHi - aLo;
|
|
const m = bHi - bLo;
|
|
const w = m + 1;
|
|
// L[i*w + j] = LCS length of a[aLo+i..aHi) and b[bLo+j..bHi)
|
|
const L = new Uint32Array((n + 1) * w);
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
for (let j = m - 1; j >= 0; j--) {
|
|
L[i * w + j] = a[aLo + i] === b[bLo + j]
|
|
? L[(i + 1) * w + j + 1] + 1
|
|
: Math.max(L[(i + 1) * w + j], L[i * w + j + 1]);
|
|
}
|
|
}
|
|
let i = 0;
|
|
let j = 0;
|
|
while (i < n && j < m) {
|
|
if (a[aLo + i] === b[bLo + j]) {
|
|
pairs.push([aLo + i, bLo + j]);
|
|
i += 1;
|
|
j += 1;
|
|
} else if (L[i * w + j + 1] >= L[(i + 1) * w + j]) {
|
|
j += 1; // keep old line i for the earliest match
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Match a[aLo..aHi) against b[bLo..bHi); pushes matched [i, j] pairs (0-based) in order. */
|
|
function matchRange(a, aLo, aHi, b, bLo, bHi, pairs) {
|
|
while (aLo < aHi && bLo < bHi && a[aLo] === b[bLo]) {
|
|
pairs.push([aLo, bLo]);
|
|
aLo += 1;
|
|
bLo += 1;
|
|
}
|
|
const tail = [];
|
|
while (aLo < aHi && bLo < bHi && a[aHi - 1] === b[bHi - 1]) {
|
|
aHi -= 1;
|
|
bHi -= 1;
|
|
tail.push([aHi, bHi]);
|
|
}
|
|
if (aLo < aHi && bLo < bHi) {
|
|
const count = new Map(); // line -> {na, nb, a, b}
|
|
for (let i = aLo; i < aHi; i++) {
|
|
const c = count.get(a[i]) ?? { na: 0, nb: 0, a: -1, b: -1 };
|
|
c.na += 1;
|
|
c.a = i;
|
|
count.set(a[i], c);
|
|
}
|
|
for (let j = bLo; j < bHi; j++) {
|
|
const c = count.get(b[j]);
|
|
if (!c) continue;
|
|
c.nb += 1;
|
|
c.b = j;
|
|
}
|
|
const anchors = [];
|
|
for (let i = aLo; i < aHi; i++) {
|
|
const c = count.get(a[i]);
|
|
if (c.na === 1 && c.nb === 1) anchors.push({ a: c.a, b: c.b });
|
|
}
|
|
if (anchors.length > 0) {
|
|
let pa = aLo;
|
|
let pb = bLo;
|
|
for (const k of lisByB(anchors)) {
|
|
const { a: ia, b: ib } = anchors[k];
|
|
matchRange(a, pa, ia, b, pb, ib, pairs);
|
|
pairs.push([ia, ib]);
|
|
pa = ia + 1;
|
|
pb = ib + 1;
|
|
}
|
|
matchRange(a, pa, aHi, b, pb, bHi, pairs);
|
|
} else {
|
|
lcsGap(a, aLo, aHi, b, bLo, bHi, pairs);
|
|
}
|
|
}
|
|
for (let k = tail.length - 1; k >= 0; k--) pairs.push(tail[k]);
|
|
}
|
|
|
|
/**
|
|
* Deterministic line diff of two operative texts.
|
|
* @param {string} oldText
|
|
* @param {string} newText
|
|
* @returns {{inside: Set<number>, oldToNew: Array<number|null>, hunks: Array<{oldStart: number, oldEnd: number, newStart: number, newEnd: number}>}}
|
|
*/
|
|
export function lineDiff(oldText, newText) {
|
|
const a = toLines(oldText);
|
|
const b = toLines(newText);
|
|
const pairs = [];
|
|
matchRange(a, 0, a.length, b, 0, b.length, pairs);
|
|
const oldToNew = new Array(a.length + 1).fill(null);
|
|
for (const [i, j] of pairs) oldToNew[i + 1] = j + 1;
|
|
const hunks = [];
|
|
const inside = new Set();
|
|
let pi = -1;
|
|
let pj = -1;
|
|
for (const [qi, qj] of [...pairs, [a.length, b.length]]) {
|
|
if (qi > pi + 1 || qj > pj + 1) {
|
|
const h = { oldStart: pi + 2, oldEnd: qi, newStart: pj + 2, newEnd: qj };
|
|
hunks.push(h);
|
|
if (h.newEnd >= h.newStart) {
|
|
for (let n = h.newStart; n <= h.newEnd; n++) inside.add(n);
|
|
} else {
|
|
if (h.newStart - 1 >= 1) inside.add(h.newStart - 1);
|
|
if (h.newStart <= b.length) inside.add(h.newStart);
|
|
}
|
|
}
|
|
pi = qi;
|
|
pj = qj;
|
|
}
|
|
return { inside, oldToNew, hunks };
|
|
}
|
|
|
|
// ---- counting (rule A, rule B) ----------------------------------------------------
|
|
|
|
// Lower index = more severe; an unrated severity counts as blocker.
|
|
const SEV_ORDER = ['blocker', 'major', 'minor'];
|
|
const rankOf = s => SEV_ORDER.indexOf(s);
|
|
const countedOf = s => normalizeSeverity(s) ?? 'blocker';
|
|
const higher = (a, b) => (rankOf(a) <= rankOf(b) ? a : b);
|
|
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
|
|
/** The rule keys of a finding: `rule_keys` when it is a list, else its one `rule_key`. */
|
|
function keysOf(f) {
|
|
const keys = Array.isArray(f.rule_keys) ? f.rule_keys : [f.rule_key];
|
|
return keys.filter(k => typeof k === 'string' && k !== '');
|
|
}
|
|
|
|
const isSecurity = f => keysOf(f).some(isSecurityKey);
|
|
|
|
function union(...lists) {
|
|
const out = [];
|
|
for (const list of lists) {
|
|
for (const x of Array.isArray(list) ? list : []) if (!out.includes(x)) out.push(x);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The rule-B record of one finding at its rated severity.
|
|
* A null or absent `lowered_from` is no record at all.
|
|
* @returns {{severity: string, record: object}}
|
|
*/
|
|
function ruleB(f, rated) {
|
|
if (f.lowered_from === undefined || f.lowered_from === null) return { severity: rated, record: {} };
|
|
const from = normalizeSeverity(f.lowered_from);
|
|
const caught = typeof f.caught_by === 'string' && f.caught_by.trim() !== '' ? f.caught_by : null;
|
|
const security = isSecurity(f);
|
|
if (from === 'major' && rated === 'minor' && caught && !security) {
|
|
return { severity: 'minor', record: { original_severity: 'major', lowered_by: `rule B: ${caught}`, caught_by: caught } };
|
|
}
|
|
let why;
|
|
if (from === null) why = 'rule B: lowered_from is not a severity; counted as blocker';
|
|
else if (from === 'blocker') why = 'rule B never lowers a blocker';
|
|
else if (from !== 'major') why = `rule B lowers only major to minor, not ${from}`;
|
|
else if (rated !== 'minor') why = `rule B lowers major to minor only; the finding is rated ${rated}`;
|
|
else if (security) why = 'rule B never lowers a security finding';
|
|
else why = 'rule B needs caught_by: the named test, Regression: command or gate that will fail on it';
|
|
const severity = higher(from ?? 'blocker', rated);
|
|
const record = { lowering_refused: why };
|
|
if (caught) record.caught_by = caught;
|
|
if (severity !== rated) record.original_severity = rated;
|
|
return { severity, record };
|
|
}
|
|
|
|
/** A current finding in today's verdict shape, plus the pass-through fields. */
|
|
function project(f) {
|
|
const rated = normalizeSeverity(f.severity);
|
|
const out = {
|
|
id: f.id ?? null,
|
|
severity: rated ?? 'blocker',
|
|
rule_key: f.rule_key ?? null,
|
|
file: f.file ?? null,
|
|
line: f.line ?? null,
|
|
text: typeof f.text === 'string' ? f.text : '',
|
|
raised_by: Array.isArray(f.raised_by) ? [...f.raised_by] : [],
|
|
};
|
|
if (rated === null) out.severity_defaulted = true;
|
|
if (Number.isInteger(f.line_end)) out.line_end = f.line_end;
|
|
if (Array.isArray(f.rule_keys)) out.rule_keys = [...f.rule_keys];
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Where a current finding sits in the operative text, or null when it has no
|
|
* plan line (another file, no line, a line in the review log, past the end,
|
|
* or a `line_end` that does not map).
|
|
*/
|
|
function placeOf(f, fullToOp, planSet) {
|
|
if (typeof f.file !== 'string' || f.file === '' || !planSet.has(resolve(f.file))) return null;
|
|
const at = l => (Number.isInteger(l) && l >= 1 && l < fullToOp.length ? fullToOp[l] ?? null : null);
|
|
const start = at(f.line);
|
|
if (start === null) return null;
|
|
const hasEnd = f.line_end !== undefined && f.line_end !== null;
|
|
let end = start;
|
|
if (hasEnd) {
|
|
const e = Number.isInteger(f.line_end) && f.line_end >= f.line ? at(f.line_end) : null;
|
|
if (e === null) return null;
|
|
end = Math.max(start, e);
|
|
}
|
|
return { start, end, hasEnd };
|
|
}
|
|
|
|
const contentSize = t => new Set(contentTokens(typeof t === 'string' ? t : '')).size;
|
|
|
|
/** Does current finding `f` (placed at `pl`, outside the diff) re-report previous finding `p`? */
|
|
function sameFinding(f, pl, p, d) {
|
|
if (typeof f.id === 'string' && f.id === p.id) return true;
|
|
const pCites = hasOwn(p, 'op_line');
|
|
if (pl && pCites && Number.isInteger(p.op_line)) {
|
|
const n = d.oldToNew[p.op_line] ?? null;
|
|
if (n !== null && n >= pl.start && n <= pl.end) return true;
|
|
}
|
|
const sameClass = (pl !== null && pCites) ||
|
|
(typeof f.file === 'string' && f.file !== '' && f.file === p.file);
|
|
if (!sameClass) return false;
|
|
if (contentSize(f.text) < ANCHOR_MIN_CONTENT_WORDS || contentSize(p.text) < ANCHOR_MIN_CONTENT_WORDS) return false;
|
|
return contentOverlap(f.text, p.text) >= DEFAULT_THRESHOLD;
|
|
}
|
|
|
|
/** A previous finding leaves the count only on its raisers' true, nobody's false. */
|
|
function isResolved(p, marks) {
|
|
const ms = marks.filter(m => m && typeof m.id === 'string' && m.id === p.id);
|
|
if (!ms.some(m => m.resolved === true)) return false;
|
|
if (ms.some(m => m.resolved === false)) return false;
|
|
const raisers = Array.isArray(p.raised_by) ? p.raised_by : [];
|
|
return raisers.every(r => ms.some(m => m.agent === r && m.resolved === true));
|
|
}
|
|
|
|
/** A previous finding carried into this round, with its matches' severities and keys. */
|
|
function carry(p, matched, d, current, prevRound) {
|
|
const pSev = countedOf(p.severity);
|
|
const orig = normalizeSeverity(p.original_severity);
|
|
const wasLowered = orig !== null && rankOf(orig) < rankOf(pSev);
|
|
const keys = union(keysOf(p), ...matched.map(m => keysOf(m.f)));
|
|
let sev;
|
|
if (wasLowered) {
|
|
if (keys.some(isSecurityKey)) sev = matched.reduce((s, m) => higher(s, m.sev), orig);
|
|
else if (matched.some(m => m.sev === 'blocker')) sev = 'blocker';
|
|
else sev = pSev;
|
|
} else {
|
|
sev = matched.reduce((s, m) => higher(s, m.sev), pSev);
|
|
}
|
|
const out = {
|
|
id: p.id ?? null,
|
|
severity: sev,
|
|
rule_key: p.rule_key ?? null,
|
|
file: p.file ?? null,
|
|
line: p.line ?? null,
|
|
text: typeof p.text === 'string' ? p.text : '',
|
|
raised_by: union(p.raised_by, ...matched.map(m => m.f.raised_by)),
|
|
};
|
|
if (p.severity_defaulted === true) out.severity_defaulted = true;
|
|
if (Number.isInteger(p.line_end)) out.line_end = p.line_end;
|
|
if (keys.length > 0) out.rule_keys = keys;
|
|
if (hasOwn(p, 'op_line')) {
|
|
// A plan line: follow the diff. Its own line edited (or no op_line already):
|
|
// keep the old line, op_line null, never re-mapped from a stale position.
|
|
const n = Number.isInteger(p.op_line) ? (d.oldToNew[p.op_line] ?? null) : null;
|
|
out.op_line = n;
|
|
if (n !== null) {
|
|
out.line = current.opToFull[n] ?? out.line;
|
|
const e = Number.isInteger(p.op_line_end) ? (d.oldToNew[p.op_line_end] ?? null) : null;
|
|
const eFull = e !== null ? (current.opToFull[e] ?? null) : null;
|
|
if (e !== null && eFull !== null) {
|
|
out.line_end = eFull;
|
|
out.op_line_end = e;
|
|
} else {
|
|
delete out.line_end;
|
|
}
|
|
} else if (hasOwn(p, 'op_line_end')) {
|
|
out.op_line_end = null;
|
|
}
|
|
}
|
|
out.carried_from = Number.isInteger(p.carried_from) ? p.carried_from : prevRound;
|
|
if (wasLowered) {
|
|
if (rankOf(sev) > rankOf(orig)) {
|
|
out.original_severity = orig;
|
|
if (typeof p.lowered_by === 'string') out.lowered_by = p.lowered_by;
|
|
if (typeof p.caught_by === 'string') out.caught_by = p.caught_by;
|
|
}
|
|
} else {
|
|
if (orig !== null && orig !== sev) out.original_severity = orig;
|
|
if (typeof p.lowering_refused === 'string') out.lowering_refused = p.lowering_refused;
|
|
if (typeof p.caught_by === 'string') out.caught_by = p.caught_by;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** One line: the reason a round is counted fully. */
|
|
const oneLine = s => String(s).replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
|
|
|
/**
|
|
* Count one critic round by rule A (from round 2, against the diff since the
|
|
* text the previous round reviewed) and record rule B (every round).
|
|
* Pure and deterministic.
|
|
* @param {{round: number, previous: object|null, base: {sha256: string, text: string}|null,
|
|
* baseNote?: string|null, current: {text: string, sha256: string, fullToOp: Array<number|null>, opToFull: Array<number|null>},
|
|
* planPaths: string[], findings: object[], marks: Array<{agent: string, id: string, resolved: boolean}>}} input
|
|
* @returns {{findings: object[], resolved: string[]|null, diffBase: string|null, diffNote: string|null, lowered: number}}
|
|
*/
|
|
export function countRound({ round, previous, base, baseNote, current, planPaths, findings, marks }) {
|
|
const prevRound = previous && Number.isInteger(previous.round) ? previous.round : null;
|
|
let diffNote = null;
|
|
let d = null;
|
|
if (Number.isInteger(round) && round >= 2 && prevRound !== null) {
|
|
if (!base || typeof base !== 'object') {
|
|
diffNote = typeof baseNote === 'string' && baseNote.trim() !== ''
|
|
? oneLine(baseNote)
|
|
: `no base snapshot of round ${prevRound}'s reviewed text; full counting`;
|
|
} else if (typeof base.sha256 !== 'string' || base.sha256 !== previous.plan_sha256) {
|
|
diffNote = `the base snapshot is not the text round ${prevRound} reviewed; full counting`;
|
|
} else if (base.sha256 === current.sha256) {
|
|
diffNote = `the plan has not changed since round ${prevRound}; full counting`;
|
|
} else if (typeof base.text !== 'string') {
|
|
diffNote = `the base snapshot of round ${prevRound} has no text; full counting`;
|
|
} else {
|
|
d = lineDiff(base.text, current.text);
|
|
}
|
|
}
|
|
const ruleA = d !== null;
|
|
const planSet = new Set(Array.isArray(planPaths) ? planPaths : []);
|
|
const fullToOp = Array.isArray(current?.fullToOp) ? current.fullToOp : [];
|
|
const prevFindings = ruleA && Array.isArray(previous.findings)
|
|
? previous.findings.filter(p => p && typeof p === 'object')
|
|
: [];
|
|
const markList = Array.isArray(marks) ? marks : [];
|
|
|
|
const matchedTo = new Map(prevFindings.map(p => [p, []]));
|
|
const fresh = [];
|
|
for (const f of Array.isArray(findings) ? findings : []) {
|
|
const o = project(f);
|
|
const b = ruleB(f, o.severity);
|
|
o.severity = b.severity;
|
|
Object.assign(o, b.record);
|
|
const pl = placeOf(f, fullToOp, planSet);
|
|
let inside = false;
|
|
if (ruleA && pl) {
|
|
for (let n = pl.start; n <= pl.end && !inside; n++) inside = d.inside.has(n);
|
|
}
|
|
if (ruleA && !inside) {
|
|
const p = prevFindings.find(q => sameFinding(f, pl, q, d));
|
|
if (p) {
|
|
matchedTo.get(p).push({ f, sev: o.severity });
|
|
continue;
|
|
}
|
|
}
|
|
if (pl) {
|
|
o.op_line = pl.start;
|
|
if (pl.hasEnd) o.op_line_end = pl.end;
|
|
}
|
|
if (ruleA && o.severity === 'major' && pl && !inside && !isSecurity(f)) {
|
|
o.severity = 'minor';
|
|
o.original_severity = 'major';
|
|
o.lowered_by = `rule A: outside the diff since round ${prevRound}`;
|
|
}
|
|
fresh.push(o);
|
|
}
|
|
|
|
const carried = [];
|
|
const resolved = [];
|
|
for (const p of prevFindings) {
|
|
const matched = matchedTo.get(p);
|
|
if (matched.length === 0 && isResolved(p, markList)) {
|
|
resolved.push(p.id);
|
|
continue;
|
|
}
|
|
carried.push(carry(p, matched, d, current, prevRound));
|
|
}
|
|
|
|
const out = [...carried, ...fresh];
|
|
const lowered = out.filter((x) => {
|
|
const o = normalizeSeverity(x.original_severity);
|
|
return o !== null && rankOf(o) < rankOf(x.severity);
|
|
}).length;
|
|
return {
|
|
findings: out,
|
|
resolved: ruleA ? resolved : null,
|
|
diffBase: ruleA ? base.sha256 : null,
|
|
diffNote,
|
|
lowered,
|
|
};
|
|
}
|