feat(review): one shared stop rule for the plan loop and review rounds (decision 217)
New lib/review/stop-rule.mjs: classOf (catalogue key, else the lower-cased dimension before the first slash, else unclassified), roundSummary (blocking = blockers + counting majors; new = no carried_from; null map for a round counted in full), decideStop (same-class / not-fewer on the last pair, round 1 alone never fires) and describeStop (one operator line with the class, the counts per round and the two recommendations). Part 1, step 1.1 of the E plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e6fb8bc12d
commit
7e09edb24f
2 changed files with 263 additions and 0 deletions
151
tests/lib/stop-rule.test.mjs
Normal file
151
tests/lib/stop-rule.test.mjs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// tests/lib/stop-rule.test.mjs
|
||||
// Part 1, step 1.1 of the E plan (decisions 217, 220): one stop rule shared by
|
||||
// the plan loop (lib/validators/plan-gate.mjs) and the review rounds
|
||||
// (lib/review/review-rounds.mjs).
|
||||
//
|
||||
// - classOf: a catalogue key as is, else the lower-cased text before the first
|
||||
// `/`, empty -> `unclassified` (217).
|
||||
// - roundSummary: blocking = blockers + counting majors; new = no
|
||||
// `carried_from`; a round counted in full has newByClass null.
|
||||
// - decideStop: judged after round N >= 2 on the pair (N-1, N); `same-class`
|
||||
// and `not-fewer`, each at its boundary; round 1 alone, a null pair (for
|
||||
// same-class) and a history-less old verdict never fire on that reason.
|
||||
// - describeStop: one line with the class, the counts per round and the two
|
||||
// recommendations, never "one more round".
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { classOf, roundSummary, decideStop, describeStop } from '../../lib/review/stop-rule.mjs';
|
||||
|
||||
const fnd = (severity, rule_key, extra = {}) => ({ severity, rule_key, text: `${severity} ${rule_key}`, ...extra });
|
||||
const r = (round, blocking, newByClass) => ({ round, blocking, newByClass });
|
||||
|
||||
// ---- classOf -------------------------------------------------------------------
|
||||
|
||||
test('classOf: a catalogue key is its own class', () => {
|
||||
assert.equal(classOf('SECURITY_INJECTION'), 'SECURITY_INJECTION');
|
||||
assert.equal(classOf('MISSING_TEST'), 'MISSING_TEST');
|
||||
// Case and spaces are ignored on the catalogue lookup, as isSecurityKey does.
|
||||
assert.equal(classOf(' security_injection '), 'SECURITY_INJECTION');
|
||||
});
|
||||
|
||||
test('classOf: otherwise the lower-cased dimension before the first slash', () => {
|
||||
assert.equal(classOf('Step-Quality/missing-verify'), 'step-quality');
|
||||
assert.equal(classOf('step-quality/other-instance'), 'step-quality');
|
||||
assert.equal(classOf('creep'), 'creep');
|
||||
assert.equal(classOf(' Gap '), 'gap');
|
||||
assert.equal(classOf('a/b/c'), 'a');
|
||||
});
|
||||
|
||||
test('classOf: empty or missing -> unclassified', () => {
|
||||
for (const k of ['', ' ', '/x', null, undefined, 42]) {
|
||||
assert.equal(classOf(k), 'unclassified', `classOf(${JSON.stringify(k)})`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- roundSummary -------------------------------------------------------------
|
||||
|
||||
test('roundSummary: blocking counts blockers and majors, never minors; new = no carried_from', () => {
|
||||
const s = roundSummary([
|
||||
fnd('blocker', 'dep/a'),
|
||||
fnd('major', 'dep/b'),
|
||||
fnd('major', 'gap', { carried_from: 1 }),
|
||||
fnd('minor', 'dep/c'),
|
||||
fnd('BLOCKER', 'MISSING_TEST'),
|
||||
fnd('SUGGESTION', 'x'),
|
||||
], { fullCount: false });
|
||||
assert.equal(s.blocking, 4);
|
||||
assert.deepEqual(s.newByClass, { dep: 2, MISSING_TEST: 1 });
|
||||
});
|
||||
|
||||
test('roundSummary: a round counted in full has newByClass null', () => {
|
||||
const s = roundSummary([fnd('blocker', 'dep/a')], { fullCount: true });
|
||||
assert.equal(s.blocking, 1);
|
||||
assert.equal(s.newByClass, null);
|
||||
});
|
||||
|
||||
test('roundSummary: no findings -> 0 and an empty map', () => {
|
||||
assert.deepEqual(roundSummary([], {}), { blocking: 0, newByClass: {} });
|
||||
});
|
||||
|
||||
// ---- decideStop ------------------------------------------------------------------
|
||||
|
||||
test('decideStop: round 1 alone never fires', () => {
|
||||
const d = decideStop([r(1, 5, { dep: 5 })]);
|
||||
assert.equal(d.fired, false);
|
||||
assert.equal(d.reason, null);
|
||||
});
|
||||
|
||||
test('decideStop: an old verdict without history never fires', () => {
|
||||
for (const h of [undefined, null, [], 'x']) {
|
||||
assert.equal(decideStop(h).fired, false, `decideStop(${JSON.stringify(h)})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('decideStop: same-class fires when one class has new blocking findings in N-1 and N', () => {
|
||||
const d = decideStop([r(1, 3, { dep: 1, gap: 2 }), r(2, 1, { dep: 1 })]);
|
||||
assert.equal(d.fired, true);
|
||||
assert.equal(d.reason, 'same-class');
|
||||
assert.deepEqual(d.classes, ['dep']);
|
||||
assert.deepEqual(d.perRound.map(p => [p.round, p.blocking]), [[1, 3], [2, 1]]);
|
||||
});
|
||||
|
||||
test('decideStop: same-class does not fire when the classes differ (boundary)', () => {
|
||||
const d = decideStop([r(1, 3, { dep: 1, gap: 2 }), r(2, 1, { creep: 1 })]);
|
||||
assert.equal(d.fired, false);
|
||||
// A class with a zero count is not "new findings of that class".
|
||||
assert.equal(decideStop([r(1, 3, { dep: 0, gap: 3 }), r(2, 1, { dep: 1 })]).fired, false);
|
||||
});
|
||||
|
||||
test('decideStop: not-fewer fires when blocking(N) > 0 and blocking(N) >= blocking(N-1)', () => {
|
||||
const eq = decideStop([r(1, 2, { dep: 2 }), r(2, 2, { gap: 2 })]);
|
||||
assert.equal(eq.fired, true);
|
||||
assert.equal(eq.reason, 'not-fewer');
|
||||
assert.deepEqual(eq.classes, ['gap']);
|
||||
const up = decideStop([r(1, 0, {}), r(2, 1, { gap: 1 })]);
|
||||
assert.equal(up.fired, true);
|
||||
assert.equal(up.reason, 'not-fewer');
|
||||
});
|
||||
|
||||
test('decideStop: not-fewer does not fire when blocking falls or reaches 0 (boundary)', () => {
|
||||
assert.equal(decideStop([r(1, 2, { dep: 2 }), r(2, 1, { gap: 1 })]).fired, false);
|
||||
assert.equal(decideStop([r(1, 0, {}), r(2, 0, {})]).fired, false);
|
||||
assert.equal(decideStop([r(1, 3, { dep: 3 }), r(2, 0, {})]).fired, false);
|
||||
});
|
||||
|
||||
test('decideStop: a null pair judges only not-fewer', () => {
|
||||
// Same class would fire, but round 2 was counted in full: skipped.
|
||||
assert.equal(decideStop([r(1, 3, { dep: 3 }), r(2, 1, null)]).fired, false);
|
||||
assert.equal(decideStop([r(1, 3, null), r(2, 1, { dep: 1 })]).fired, false);
|
||||
const d = decideStop([r(1, 1, { dep: 1 }), r(2, 1, null)]);
|
||||
assert.equal(d.fired, true);
|
||||
assert.equal(d.reason, 'not-fewer');
|
||||
});
|
||||
|
||||
test('decideStop: only the last pair is judged', () => {
|
||||
// Rounds 1-2 would fire (same class, not fewer); rounds 2-3 do not.
|
||||
const d = decideStop([r(1, 2, { dep: 2 }), r(2, 3, { dep: 1 }), r(3, 1, { gap: 1 })]);
|
||||
assert.equal(d.fired, false);
|
||||
assert.equal(d.perRound.length, 3);
|
||||
});
|
||||
|
||||
// ---- describeStop ----------------------------------------------------------------
|
||||
|
||||
test('describeStop: the class, the counts per round and the two recommendations, never "one more round"', () => {
|
||||
const d = decideStop([r(1, 3, { dep: 1, gap: 2 }), r(2, 1, { dep: 1 })]);
|
||||
const line = describeStop(d);
|
||||
assert.equal(typeof line, 'string');
|
||||
assert.ok(!line.includes('\n'), 'one line');
|
||||
assert.match(line, /same-class/);
|
||||
assert.match(line, /\bdep\b/);
|
||||
assert.match(line, /round 1: 3 blocking/);
|
||||
assert.match(line, /round 2: 1 blocking/);
|
||||
assert.match(line, /known issue/i);
|
||||
assert.match(line, /new mechanism/i);
|
||||
assert.doesNotMatch(line, /one more round/i);
|
||||
const nf = describeStop(decideStop([r(1, 2, { dep: 2 }), r(2, 2, { gap: 2 })]));
|
||||
assert.match(nf, /not-fewer/);
|
||||
assert.match(nf, /round 1: 2 blocking/);
|
||||
assert.match(nf, /round 2: 2 blocking/);
|
||||
assert.doesNotMatch(nf, /one more round/i);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue