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
112
lib/review/stop-rule.mjs
Normal file
112
lib/review/stop-rule.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// lib/review/stop-rule.mjs
|
||||
// The stop rule (decisions 217, 220): when a review loop gets no new round.
|
||||
// One rule, read by the plan loop (lib/validators/plan-gate.mjs, the critic
|
||||
// rounds) and by the review rounds (lib/review/review-rounds.mjs).
|
||||
//
|
||||
// A new round is worth running only while there is an open blocking finding
|
||||
// and the blocking findings get fewer. The rule is judged after round N >= 2,
|
||||
// on the pair (N-1, N), and fires on either reason:
|
||||
// - `same-class`: one finding class has new blocking findings in N-1 and in
|
||||
// N (two rounds in a row of the same kind of finding: the loop is finding
|
||||
// instances of a class, not converging on it). Skipped for a pair where
|
||||
// either round was counted in full (newByClass null: no carry, so "new"
|
||||
// cannot be told from "carried").
|
||||
// - `not-fewer`: blocking(N) > 0 and blocking(N) >= blocking(N-1).
|
||||
// Round 1 alone never fires, and a history that starts later (an older verdict
|
||||
// without one) fires only once it holds two rounds.
|
||||
//
|
||||
// A finding's class (217): its catalogue rule key when it has one
|
||||
// (lib/review/rule-catalogue.mjs), otherwise the reviewer's dimension, the
|
||||
// part of `rule_key` before the first `/`, lower-cased; empty -> `unclassified`.
|
||||
// Coarse on purpose: it stops early and often, and a loop cannot form.
|
||||
//
|
||||
// When it fires the operator gets the class and the counts per round with two
|
||||
// recommendations, "known issue" or "new mechanism" — never another round.
|
||||
//
|
||||
// Credit: the `not-fewer` signal covers what Addy Osmani calls "doubt theater"
|
||||
// (addyosmani/agent-skills, MIT): rounds of review that keep raising doubt
|
||||
// without reducing it.
|
||||
//
|
||||
// Pure: no I/O.
|
||||
|
||||
import { getRule } from './rule-catalogue.mjs';
|
||||
|
||||
export const STOP_REASONS = Object.freeze(['same-class', 'not-fewer']);
|
||||
|
||||
/** A finding's class (217): the catalogue key, else the lower-cased dimension, else `unclassified`. */
|
||||
export function classOf(ruleKey) {
|
||||
if (typeof ruleKey !== 'string') return 'unclassified';
|
||||
const entry = getRule(ruleKey.trim().toUpperCase());
|
||||
if (entry) return entry.rule_key;
|
||||
const dim = ruleKey.split('/')[0].trim().toLowerCase();
|
||||
return dim === '' ? 'unclassified' : dim;
|
||||
}
|
||||
|
||||
/** Blocking = anything but minor or suggestion (an unrated finding counts, as the plan gate counts it as a blocker). */
|
||||
const isBlocking = f => !['minor', 'suggestion'].includes(String(f?.severity ?? '').trim().toLowerCase());
|
||||
|
||||
/**
|
||||
* One round's summary.
|
||||
* @param {object[]} findings - the round's counted findings (blockers and
|
||||
* majors still counting, plus anything else, which is ignored)
|
||||
* @param {{fullCount?: boolean}} [opts] - fullCount: the round was counted in
|
||||
* full (no diff base, no carry), so newByClass is null
|
||||
* @returns {{blocking: number, newByClass: Object<string, number>|null}}
|
||||
* newByClass: per class, the blocking findings with no `carried_from`
|
||||
*/
|
||||
export function roundSummary(findings, { fullCount = false } = {}) {
|
||||
const blocking = (Array.isArray(findings) ? findings : []).filter(f => f && typeof f === 'object' && isBlocking(f));
|
||||
if (fullCount) return { blocking: blocking.length, newByClass: null };
|
||||
const newByClass = {};
|
||||
for (const f of blocking) {
|
||||
if (f.carried_from !== undefined && f.carried_from !== null) continue;
|
||||
const c = classOf(f.rule_key);
|
||||
newByClass[c] = (newByClass[c] ?? 0) + 1;
|
||||
}
|
||||
return { blocking: blocking.length, newByClass };
|
||||
}
|
||||
|
||||
const count = n => (Number.isInteger(n) && n >= 0 ? n : 0);
|
||||
const classesIn = m => (m && typeof m === 'object' ? Object.keys(m).filter(k => count(m[k]) > 0).sort() : []);
|
||||
|
||||
/**
|
||||
* Judge the stop rule after the last round of `rounds` (oldest first).
|
||||
* @param {Array<{round?: number, blocking: number, newByClass: Object<string, number>|null}>} rounds
|
||||
* @returns {{fired: boolean, reason: 'same-class'|'not-fewer'|null, classes: string[],
|
||||
* perRound: Array<{round: number, blocking: number, newByClass: Object<string, number>|null}>}}
|
||||
*/
|
||||
export function decideStop(rounds) {
|
||||
const list = (Array.isArray(rounds) ? rounds : []).filter(x => x && typeof x === 'object');
|
||||
const perRound = list.map((x, i) => ({
|
||||
round: Number.isInteger(x.round) ? x.round : i + 1,
|
||||
blocking: count(x.blocking),
|
||||
newByClass: x.newByClass && typeof x.newByClass === 'object' ? { ...x.newByClass } : null,
|
||||
}));
|
||||
const none = { fired: false, reason: null, classes: [], perRound };
|
||||
if (perRound.length < 2) return none;
|
||||
const prev = perRound[perRound.length - 2];
|
||||
const cur = perRound[perRound.length - 1];
|
||||
if (prev.newByClass !== null && cur.newByClass !== null) {
|
||||
const before = new Set(classesIn(prev.newByClass));
|
||||
const shared = classesIn(cur.newByClass).filter(c => before.has(c));
|
||||
if (shared.length > 0) return { fired: true, reason: 'same-class', classes: shared, perRound };
|
||||
}
|
||||
if (cur.blocking > 0 && cur.blocking >= prev.blocking) {
|
||||
return { fired: true, reason: 'not-fewer', classes: classesIn(cur.newByClass), perRound };
|
||||
}
|
||||
return none;
|
||||
}
|
||||
|
||||
/** The one line the operator gets when the stop rule fired (empty string when it did not). */
|
||||
export function describeStop(decision) {
|
||||
if (!decision || decision.fired !== true) return '';
|
||||
const rounds = Array.isArray(decision.perRound) ? decision.perRound : [];
|
||||
const fmtNew = m => (m === null ? 'new not counted (full count)' : (classesIn(m).map(c => `${c} ${m[c]}`).join(', ') || 'none') + ' new');
|
||||
const counts = rounds.map(p => `round ${p.round}: ${p.blocking} blocking (${fmtNew(p.newByClass)})`).join('; ');
|
||||
const classes = Array.isArray(decision.classes) && decision.classes.length > 0 ? decision.classes.join(', ') : '(none new)';
|
||||
const why = decision.reason === 'same-class'
|
||||
? 'new blocking findings of the same class two rounds in a row'
|
||||
: 'the blocking findings did not get fewer';
|
||||
return `stop rule (${decision.reason}): ${why}; class: ${classes}; ${counts}. ` +
|
||||
'Recommendation: known issue (accept the open findings and go on) or new mechanism (stop; the direction goes to the operator).';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue