fix(review): group a triplet's findings title first, so arrival order never changes the rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-23 18:30:52 +02:00
commit a5fcb5e8b4
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 339 additions and 37 deletions

View file

@ -79,9 +79,14 @@ re-reading, and fresh finding generation are all forbidden.
Two findings collide when their `(file, line, rule_key)` triplets are
identical, with one exception: **one reviewer never duplicates itself.** Two
findings from the same reviewer on one triplet under different titles are two
findings (an engine's catch-all `rule_key` makes this common): keep both rows,
in input order. A finding from another reviewer joins the row whose title it
shares, else the first one. When findings collide:
findings (an engine's catch-all `rule_key` makes this common): keep both rows.
Group in two passes, so the order the findings arrive in never changes a row:
first every finding joins the row of its title (case and outer spaces
ignored), over all of the triplet's findings; then each title row, in
alphabetical order of its title, joins the first row that holds no finding of
one of its reviewers under another title, else it stays a row of its own. A
finding without a title joins the first row it fits, last. When findings
collide:
- Rank the colliding findings and keep the first:
1. the higher catalogue tier: the severity `lib/review/rule-catalogue.mjs`
gives the `rule_key`, or the finding's own severity when the key is
@ -93,7 +98,10 @@ shares, else the first one. When findings collide:
3. among findings of one kind, the higher asserted severity;
4. among reviewer-agent findings, prefer the finding from
`brief-conformance-reviewer` (its findings are anchored to the brief);
5. then the first seen.
5. then the first seen in the row's order: its title groups
alphabetically, each title's findings in canonical order (reviewer,
detail, the whole finding), findings without a title last in that
order, never the order the findings arrived in.
- Write the kept finding with the highest severity any finding in its
group asserted, so dropping a duplicate never lowers the group and an
engine's brief-conflict mark never lowers what a reviewer agent asserted.

View file

@ -19,7 +19,7 @@
//
// What IS implemented, purely: Pass 1 (triplet dedup → survivor ranked by
// catalogue tier, then reviewer over engine, asserted severity, conformance,
// first seen; the survivor carries its group's highest asserted severity
// first in the group's order; the survivor carries its group's highest asserted severity
// (review bc7f4e23) + detail concat + raised_by provenance; when that raises
// it, `pass1_raised_from` keeps what the survivor itself asserted, read by
// Pass 3 and never returned: `original_severity` is what the kept finding
@ -254,9 +254,10 @@ export function ingest(reviewerPayloads) {
* (1) the catalogue tier (`getRule(rule_key)?.severity`, else the finding's own
* severity; lower `severityRank` wins), (2) a reviewer-agent finding over an
* engine finding, (3) among findings of one kind the higher asserted severity,
* (4) the conformance reviewer, (5) first seen. It is written with the highest
* severity any finding in its group asserted, so dropping a duplicate never
* lowers the group (review bc7f4e23; plan 2026-10-11-files-backticks-trial4,
* (4) the conformance reviewer, (5) the first in the group's order (the order
* the two passes below place the members in, never the arrival order). It is
* written with the highest severity any finding in its group asserted, so
* dropping a duplicate never lowers the group (review bc7f4e23; plan 2026-10-11-files-backticks-trial4,
* ADR "Pass 1 survivor order and group severity"). Carries raised_by
* provenance, concatenates other reviewers' attribution into detail, and
* recomputes the id over the triplet.
@ -267,23 +268,34 @@ export function dedupByTriplet(findings) {
// One reviewer never duplicates itself: its second finding on a triplet under
// another title is another finding and opens a group of its own, which gets
// its own row and the id over `rule_key#2` (voyage-lekerepo trial 5,
// finding 7). Another reviewer's finding joins the group whose title it
// shares, else the first one it can join.
// finding 7). Inside one triplet the findings are grouped in two passes, so
// the order the reviewers' findings arrive in never changes which row a
// finding joins (plan 2026-10-20-fix-round-serious-findings, ADR "ORD: two
// passes over the triplet, title first"): (1) every finding with a title
// joins the class of its normalized title, over all of the triplet's
// findings; (2) the title classes, in alphabetical order of the normalized
// title, each join the first earlier group whose every member fits every
// finding of the class (another reviewer, or the same title), else they form
// a group of their own; findings without a title come last, in canonical
// order (reviewer, detail, the whole finding), each joining the first group
// it fits. Members inside a group keep the order the passes place them in:
// the classes alphabetically, each class's findings in canonical order, the
// untitled findings last in canonical order. Rank (5) of the survivor reads
// that order, so a full tie never follows arrival order either: the survivor
// decides Pass 2's actionability and Pass 3's brief-conflict lowering, and so
// the verdict. A triplet's groups keep the order of their earliest input
// index, so the rows and `#k` ids stay in input order.
const tripletGroups = new Map();
for (const f of findings) {
findings.forEach((f, i) => {
const key = tripletKey(f);
if (!tripletGroups.has(key)) tripletGroups.set(key, []);
const subs = tripletGroups.get(key);
const fits = (g) => g.every((m) => m.reviewer !== f.reviewer || sameTitle(m, f));
let sub = subs.find((g) => fits(g) && g.some((m) => sameTitle(m, f) && m.title !== undefined && f.title !== undefined))
?? subs.find(fits);
if (!sub) {
sub = [];
subs.push(sub);
}
sub.push(f);
}
const groups = [...tripletGroups.values()].flat();
tripletGroups.get(key).push({ f, i });
});
const groups = [...tripletGroups.values()].flatMap((entries) => groupTriplet(
entries,
(a, b) => a.reviewer !== b.reviewer || sameTitle(a, b),
(f) => f.reviewer,
));
const out = [];
for (const group of groups) {
// Ranked as the JSDoc above says. A brief-conflict mark is not carried over
@ -346,6 +358,56 @@ function sameTitle(a, b) {
return String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
}
/** Code-unit order of two strings (never the locale's). */
function cmpStr(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* The groups of one triplet's findings, in the two passes `dedupByTriplet`
* describes. `entries` are `{ f, i }` (the finding and its input index);
* `fits(m, f)` says member `m` may share a group with `f`; `whoOf(f)` names the
* reviewer for the canonical order. Returns arrays of findings, each in the
* order the passes place them (classes alphabetically, each class in canonical
* order, untitled findings last in canonical order), the groups in the order
* of their earliest input index.
* @param {Array<{f: object, i: number}>} entries
* @param {(m: object, f: object) => boolean} fits
* @param {(f: object) => unknown} whoOf
* @returns {object[][]}
*/
function groupTriplet(entries, fits, whoOf) {
const canon = (x, y) => cmpStr(String(whoOf(x.f) ?? ''), String(whoOf(y.f) ?? ''))
|| cmpStr(String(x.f.detail ?? ''), String(y.f.detail ?? ''))
|| cmpStr(JSON.stringify(x.f), JSON.stringify(y.f));
const classes = new Map();
const untitled = [];
for (const e of entries) {
if (e.f.title === undefined) {
untitled.push(e);
continue;
}
const t = String(e.f.title).trim().toLowerCase();
if (!classes.has(t)) classes.set(t, []);
classes.get(t).push(e);
}
const groups = [];
const place = (members) => {
let g = groups.find((grp) => grp.every((m) => members.every((e) => fits(m.f, e.f))));
if (!g) {
g = [];
groups.push(g);
}
g.push(...members);
};
for (const t of [...classes.keys()].sort(cmpStr)) place(classes.get(t).sort(canon));
for (const e of untitled.sort(canon)) place([e]);
return groups
.map((g) => ({ first: Math.min(...g.map((e) => e.i)), members: g.map((e) => e.f) }))
.sort((a, b) => a.first - b.first)
.map((g) => g.members);
}
/**
* The tiers whose over-long text may be trimmed. Length is packaging, never a
* claim: a BLOCKER or MAJOR keeps every character, and nothing is ever removed

View file

@ -123,7 +123,8 @@ const VERDICT_SCHEMA = {
// first (for a catalogue key it always ties inside one triplet, since the
// rule_key is shared), then a reviewer finding (no `engine`) over an engine
// finding (decision 13, operator 12.10), then the higher asserted severity;
// the workflow has no conformance rule, so otherwise the first-seen one stays.
// the workflow has no conformance rule, so otherwise the first in the group's
// order stays (the order the two passes below place the members in).
// The survivor carries the highest severity any finding in its group asserted,
// so an engine's mark never lowers what a reviewer agent asserted.
// As in coordinator-contract Pass 1, one source never duplicates itself: its
@ -131,25 +132,64 @@ const VERDICT_SCHEMA = {
// (voyage-lekerepo trial 5, finding 7). `sourceOf` names the reviewer or engine.
const sameTitle = (a, b) => a.title === undefined || b.title === undefined ||
String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
// As there too, a triplet's findings are grouped in two passes, so the order
// they arrive in never changes which row a finding joins: (1) every titled
// finding joins the class of its normalized title, over all of the triplet's
// findings; (2) the classes, in alphabetical order of the normalized title,
// each join the first earlier group whose every member fits every finding of
// the class (another source, or the same title), else they form a group of
// their own; untitled findings come last, in canonical order (source, detail,
// the whole finding), each joining the first group it fits. Members keep the
// order the passes place them in (the classes alphabetically, each class's
// findings in canonical order, the untitled findings last), so a full tie on
// the survivor never follows arrival order either; a triplet's groups keep the
// order of their earliest input index. With `perSource: false` everything on a
// triplet is one group, as before.
const sourceOf = new Map();
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
function groupTriplet(entries) {
const srcOf = (f) => sourceOf.get(f) ?? null;
const fits = (m, f) => srcOf(m) !== srcOf(f) || sameTitle(m, f);
const canon = (x, y) => cmpStr(String(srcOf(x.f) ?? ''), String(srcOf(y.f) ?? ''))
|| cmpStr(String(x.f.detail ?? ''), String(y.f.detail ?? ''))
|| cmpStr(JSON.stringify(x.f), JSON.stringify(y.f));
const classes = new Map();
const untitled = [];
for (const e of entries) {
if (e.f.title === undefined) {
untitled.push(e);
continue;
}
const t = String(e.f.title).trim().toLowerCase();
if (!classes.has(t)) classes.set(t, []);
classes.get(t).push(e);
}
const groups = [];
const place = (members) => {
let g = groups.find((grp) => grp.every((m) => members.every((e) => fits(m.f, e.f))));
if (!g) {
g = [];
groups.push(g);
}
g.push(...members);
};
for (const t of [...classes.keys()].sort(cmpStr)) place(classes.get(t).sort(canon));
for (const e of untitled.sort(canon)) place([e]);
return groups
.map((g) => ({ first: Math.min(...g.map((e) => e.i)), members: g.map((e) => e.f) }))
.sort((a, b) => a.first - b.first)
.map((g) => g.members);
}
function dedupByTriplet(findings, { perSource = true } = {}) {
const byTriplet = new Map();
for (const f of findings) {
findings.forEach((f, i) => {
const key = tripletOf(f);
if (!byTriplet.has(key)) byTriplet.set(key, []);
const subs = byTriplet.get(key);
const src = sourceOf.get(f) ?? null;
const fits = (g) => g.every((m) => (sourceOf.get(m) ?? null) !== src || sameTitle(m, f));
let sub = !perSource ? subs[0]
: subs.find((g) => fits(g) && g.some((m) => m.title !== undefined && f.title !== undefined && sameTitle(m, f)))
?? subs.find(fits);
if (!sub) {
sub = [];
subs.push(sub);
}
sub.push(f);
}
const groups = [...byTriplet.values()].flat();
byTriplet.get(key).push({ f, i });
});
const groups = [...byTriplet.values()].flatMap((entries) => (perSource
? groupTriplet(entries)
: [entries.map((e) => e.f)]));
const tier = (f) => sevRank(tierOf(f.rule_key) ?? f.severity);
const out = [];
for (const group of groups) {

View file

@ -455,3 +455,126 @@ test('runContract — the #k id counts rows of one triplet in the final list, so
assert.equal(result.findings[0].id, computeFindingId('bin/slug.mjs', 46, 'MISSING_ERROR_HANDLING'), 'what fill-finding-ids computes for a review.md holding this row alone');
assert.equal(result.trimmed_count, 1, 'the trimmed row that Pass 2 set aside is not counted');
});
// ---- ORD — the rows a finding joins do not depend on arrival order ---------
/** Every arrival order of `xs`. */
function orders(xs) {
if (xs.length <= 1) return [xs];
return xs.flatMap((x, i) => orders([...xs.slice(0, i), ...xs.slice(i + 1)]).map((rest) => [x, ...rest]));
}
/** A row's members, rebuilt from its survivor plus each "Also flagged by" line, as sorted `reviewer|title` strings. */
function members(row) {
const out = [`${row.reviewer}|${row.title}`];
for (const m of String(row.detail ?? '').matchAll(/^Also flagged by (.+?): (.+)\.$/gm)) out.push(`${m[1]}|${m[2]}`);
return out.sort();
}
const ORD_C = 'code-correctness-reviewer';
const ORD_F = 'brief-conformance-reviewer';
test('[ORD] correctness A, conformance B, correctness B give the rows {correctness A} and {conformance B, correctness B} in every arrival order', () => {
const at = { file: 'bin/slug.mjs', line: 46, rule_key: 'MISSING_TEST', detail: 'd' };
const cA = { ...at, severity: 'MAJOR', title: 'A', reviewer: ORD_C };
const fB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_F };
const cB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_C };
const expected = [[`${ORD_C}|A`], [`${ORD_F}|B`, `${ORD_C}|B`]];
const all = orders([['cA', cA], ['fB', fB], ['cB', cB]]);
assert.equal(all.length, 6);
for (const order of all) {
const name = order.map(([n]) => n).join(', ');
const rows = dedupByTriplet(order.map(([, f]) => structuredClone(f)));
assert.equal(rows.length, 2, `${name}: ${JSON.stringify(rows.map(members))}`);
const got = rows.map(members).sort((x, y) => x.length - y.length || x.join().localeCompare(y.join()));
assert.deepEqual(got, expected, `order ${name}`);
}
});
test('[ORD] with an off-catalogue rule_key and cA MINOR, fB MAJOR, cB MINOR, every arrival order gives {cA} MINOR, {fB, cB} MAJOR and one verdict', () => {
const at = { file: 'bin/slug.mjs', line: 46, rule_key: 'NOT_IN_THE_CATALOGUE', detail: 'd' };
const cA = { ...at, severity: 'MINOR', title: 'A', reviewer: ORD_C };
const fB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_F };
const cB = { ...at, severity: 'MINOR', title: 'B', reviewer: ORD_C };
const expected = { [`${ORD_C}|A`]: 'MINOR', [`${ORD_F}|B+${ORD_C}|B`]: 'MAJOR' };
const verdicts = new Set();
for (const order of orders([['cA', cA], ['fB', fB], ['cB', cB]])) {
const name = order.map(([n]) => n).join(', ');
const rows = dedupByTriplet(order.map(([, f]) => structuredClone(f)));
const bySet = Object.fromEntries(rows.map((r) => [members(r).join('+'), r.severity]));
assert.deepEqual(bySet, expected, `order ${name}`);
verdicts.add(JSON.stringify(computeVerdict(rows)));
}
assert.equal(verdicts.size, 1, [...verdicts].join(' | '));
assert.equal(JSON.parse([...verdicts][0]).verdict, 'WARN');
});
/** The kept and unverified rows of a run, as sorted `reviewer|title|severity` strings, so two runs can be compared. */
function split(result) {
const key = (f) => `${f.reviewer}|${String(f.title ?? '').trim().toLowerCase()}|${f.severity}`;
return { kept: result.findings.map(key).sort(), unverified: result.unverified.map(key).sort() };
}
test('[ORD] a full tie on one row: one reviewer\'s two same-title BLOCKERs, one with an empty recommended_action, give one verdict in both arrival orders', () => {
const at = { file: 'x.mjs', line: 3, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER' };
const empty = { ...at, title: 'Injection', recommended_action: '' };
const full = { ...at, title: 'injection ', recommended_action: 'Quote the argument.' };
const a = runContract([{ reviewer: ORD_C, findings: [structuredClone(empty), structuredClone(full)] }]);
const b = runContract([{ reviewer: ORD_C, findings: [structuredClone(full), structuredClone(empty)] }]);
assert.equal(a.verdict, b.verdict, `order (empty, full) gives ${a.verdict}, order (full, empty) gives ${b.verdict}`);
assert.deepEqual(split(a), split(b), 'the kept/unverified split');
});
test('[ORD] a full tie on one row: two /code-review MAJORs titled T, one marked as contradicting the brief, give one verdict in both arrival orders', () => {
const at = { file: 'x.mjs', line: 3, rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'T' };
const marked = { ...at, contradicts_brief: 'Non-Goal: no new runtime dependency' };
const plain = { ...at };
const a = runContract([{ reviewer: 'claude-code-review', findings: [structuredClone(marked), structuredClone(plain)] }]);
const b = runContract([{ reviewer: 'claude-code-review', findings: [structuredClone(plain), structuredClone(marked)] }]);
assert.equal(a.verdict, b.verdict, `marked first gives ${a.verdict}, unmarked first gives ${b.verdict}`);
assert.deepEqual(split(a), split(b), 'the kept/unverified split');
});
/** A row's member set, rebuilt from its survivor plus each "Also flagged by" line; an untitled member is named by its rule_key, as the line names it. */
function memberSet(row) {
const out = [`${row.reviewer}|${row.title ?? row.rule_key}`];
for (const m of String(row.detail ?? '').matchAll(/^Also flagged by (.+?): (.+)\.$/gm)) out.push(`${m[1]}|${m[2]}`);
return out.sort().join('+');
}
test('[ORD] the title classes are placed in alphabetical order: cA, fB, cB and an untitled correctness BLOCKER give {cA, cU} BLOCKER and {fB, cB} MAJOR and one verdict in all 24 orders', () => {
const K = 'NOT_IN_THE_CATALOGUE';
const at = { file: 'bin/slug.mjs', line: 46, rule_key: K, detail: 'd' };
// cA and cU tie on every survivor rule, so the row's first member in the
// group's order (class A before the untitled findings) is kept: cA, whose
// empty recommended_action sets the row aside as unverified.
const cA = { ...at, severity: 'BLOCKER', title: 'A', reviewer: ORD_C, recommended_action: '' };
const fB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_F };
const cB = { ...at, severity: 'MINOR', title: 'B', reviewer: ORD_C };
const cU = { ...at, severity: 'BLOCKER', reviewer: ORD_C };
const expected = {
[`${ORD_C}|A+${ORD_C}|${K}`]: 'BLOCKER',
[`${ORD_F}|B+${ORD_C}|B`]: 'MAJOR',
};
const all = orders([['cA', cA], ['fB', fB], ['cB', cB], ['cU', cU]]);
assert.equal(all.length, 24);
const verdicts = new Set();
for (const order of all) {
const name = order.map(([n]) => n).join(', ');
const rows = dedupByTriplet(order.map(([, f]) => structuredClone(f)));
const bySet = Object.fromEntries(rows.map((r) => [memberSet(r), r.severity]));
assert.deepEqual(bySet, expected, `order ${name}`);
const j = judgeFilter(rows);
verdicts.add(JSON.stringify({
kept: j.kept.map(memberSet).sort(),
unverified: j.unverified.map(memberSet).sort(),
verdict: computeVerdict(j.kept, { unverified: j.unverified }).verdict,
}));
}
assert.equal(verdicts.size, 1, [...verdicts].join(' | '));
assert.deepEqual(JSON.parse([...verdicts][0]), {
kept: [`${ORD_F}|B+${ORD_C}|B`],
unverified: [`${ORD_C}|A+${ORD_C}|${K}`],
verdict: 'WARN',
});
});

View file

@ -1382,3 +1382,11 @@ test('[DJ] review-coordinator body section 9 copies every reviewer Declined to J
assert.match(body, /then every line a reviewer wrote under its own `### Declined to Judge`, copied as `\{reviewer\}: \{line\}` \(they are not findings: they stay out of the JSON `declined_to_judge` array and do not change the verdict\)/,
'body section 9 no longer copies the reviewers\' Declined to Judge lines');
});
test('[ORD] sibling: agents/review-coordinator.md — Pass 1 groups in two passes, title first, with no "else the first one"', () => {
const pass1 = flat(section(read(COORD), '### Pass 1 — Dedup by `(file, line, rule_key)` triplet'));
assert.doesNotMatch(pass1, /else the first one/, 'Pass 1 still lets a finding fall back to the first row in arrival order');
assert.doesNotMatch(pass1, /keep both rows, in input order/, 'Pass 1 still keeps the rows in input order');
assert.match(pass1, /Group in two passes, so the order the findings arrive in never changes a row: first every finding joins the row of its title/,
'Pass 1 does not name the two passes, title first');
});

View file

@ -637,3 +637,64 @@ test('armB — two different /code-review findings on one triplet stay two rows
});
assert.deepEqual(c.merged.map((f) => f.title).sort(), ['A lone CR does not split lines', 'EAGAIN on stdin']);
});
// ---- ORD sibling: the workflow's inlined dedup does not depend on arrival order ----
test('[ORD] sibling: workflow — conformance B with /code-review A and B, in both engine orders, gives one row of A alone and one row of both B findings', async () => {
const K = 'CODE_REVIEW_DEFECT';
const engineA = F(K, 'MINOR', { title: 'A' });
const engineB = F(K, 'MAJOR', { title: 'B' });
for (const codeFindings of [[engineA, engineB], [engineB, engineA]]) {
const name = codeFindings.map((f) => f.title).join(', ');
const c = await floorCase({
reviewerFindings: [F(K, 'MINOR', { title: 'B' })],
codeFindings,
returned: [],
verdict: 'WARN',
});
const rows = c.merged.filter((m) => triplet(m) === triplet(F(K, 'MINOR')));
assert.equal(rows.length, 2, `engine order ${name}: ${JSON.stringify(rows)}`);
const got = rows
.map((m) => ({ title: m.title, engine: m.engine ?? null, severity: m.severity }))
.sort((x, y) => x.title.localeCompare(y.title));
// A alone: the /code-review A finding survives with its own MINOR. Both B
// findings: the conformance B finding survives (a reviewer over an engine)
// at the MAJOR the /code-review B finding asserted.
assert.deepEqual(got, [
{ title: 'A', engine: 'code-review', severity: 'MINOR' },
{ title: 'B', engine: null, severity: 'MAJOR' },
], `engine order ${name}`);
}
});
test('[ORD] sibling: workflow — the title classes are placed in alphabetical order: conformance B with /code-review A, B and an untitled BLOCKER gives {A, untitled} BLOCKER kept as A and {B, B} MAJOR in all 6 engine orders', async () => {
const K = 'CODE_REVIEW_DEFECT';
const engineA = F(K, 'BLOCKER', { title: 'A' });
const engineB = F(K, 'MINOR', { title: 'B' });
const { title: _t, ...engineU } = F(K, 'BLOCKER');
// The workflow reads the reviewer findings first, so class B always arrives
// first: only the alphabetical order puts the untitled finding in A's row.
// A and the untitled finding tie on every survivor rule, so the first in the
// group's order (class A before the untitled findings) is kept.
const engineOrders = [
[engineA, engineB, engineU], [engineA, engineU, engineB], [engineB, engineA, engineU],
[engineB, engineU, engineA], [engineU, engineA, engineB], [engineU, engineB, engineA],
];
for (const codeFindings of engineOrders) {
const name = codeFindings.map((f) => f.title ?? 'U').join(', ');
const c = await floorCase({
reviewerFindings: [F(K, 'MAJOR', { title: 'B' })],
codeFindings,
returned: [],
verdict: 'WARN',
});
const rows = c.merged.filter((m) => triplet(m) === triplet(F(K, 'MINOR')));
const got = rows
.map((m) => ({ title: m.title ?? null, engine: m.engine ?? null, severity: m.severity }))
.sort((x, y) => String(x.title).localeCompare(String(y.title)));
assert.deepEqual(got, [
{ title: 'A', engine: 'code-review', severity: 'BLOCKER' },
{ title: 'B', engine: null, severity: 'MAJOR' },
], `engine order ${name}`);
}
});