@verevoir/review 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/footer.d.ts +3 -1
- package/dist/cli/footer.js +10 -1
- package/dist/cli/review.js +14 -3
- package/dist/dedup.d.ts +6 -0
- package/dist/dedup.js +19 -1
- package/dist/practice-lenses.d.ts +9 -0
- package/dist/practice-lenses.js +16 -4
- package/dist/prompt.d.ts +3 -0
- package/dist/report.js +9 -5
- package/dist/reviewer-anthropic.js +7 -1
- package/dist/run-review.d.ts +8 -0
- package/dist/run-review.js +10 -3
- package/dist/tier.d.ts +22 -7
- package/dist/tier.js +111 -27
- package/dist/verdict.d.ts +3 -0
- package/package.json +1 -1
- package/src/cli/footer.ts +10 -0
- package/src/cli/review.ts +12 -3
- package/src/dedup.ts +21 -1
- package/src/practice-lenses.ts +26 -5
- package/src/prompt.ts +3 -0
- package/src/report.ts +11 -5
- package/src/reviewer-anthropic.ts +7 -1
- package/src/run-review.ts +18 -3
- package/src/tier.ts +127 -23
- package/src/verdict.ts +3 -0
package/dist/cli/footer.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export type RouteName = 'env' | 'key-file' | 'claude-cli';
|
|
|
4
4
|
export declare function modelText(requested: string, calls: readonly RawCall[]): string;
|
|
5
5
|
/** The one line saying how many [blocking] findings tier enforcement downgraded; none for 0. */
|
|
6
6
|
export declare function tierLine(downgraded: number): string | undefined;
|
|
7
|
-
|
|
7
|
+
/** The one line saying how many smells tier enforcement promoted to [blocking]; none for 0. */
|
|
8
|
+
export declare function promotedLine(promoted: number): string | undefined;
|
|
9
|
+
export declare function renderFooter(requested: string, route: RouteName, calls: readonly RawCall[], downgraded?: number, promoted?: number): string;
|
|
8
10
|
/** One stderr line per lens. */
|
|
9
11
|
export declare function usageLines(calls: readonly RawCall[]): string[];
|
package/dist/cli/footer.js
CHANGED
|
@@ -36,11 +36,20 @@ export function tierLine(downgraded) {
|
|
|
36
36
|
? `Tier: ${String(downgraded)} blocking finding(s) downgraded: no Reject/Expect signal quoted`
|
|
37
37
|
: undefined;
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
/** The one line saying how many smells tier enforcement promoted to [blocking]; none for 0. */
|
|
40
|
+
export function promotedLine(promoted) {
|
|
41
|
+
return promoted > 0
|
|
42
|
+
? `Tier: ${String(promoted)} finding(s) promoted: quotes a Reject signal`
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
export function renderFooter(requested, route, calls, downgraded = 0, promoted = 0) {
|
|
40
46
|
const lines = ['---', '', `Judged with ${modelText(requested, calls)}, route: ${route}.`];
|
|
41
47
|
const tier = tierLine(downgraded);
|
|
42
48
|
if (tier !== undefined)
|
|
43
49
|
lines.push('', tier);
|
|
50
|
+
const promotion = promotedLine(promoted);
|
|
51
|
+
if (promotion !== undefined)
|
|
52
|
+
lines.push('', promotion);
|
|
44
53
|
if (calls.length > 0) {
|
|
45
54
|
lines.push('', 'Per-lens usage and wall time:', '');
|
|
46
55
|
for (const call of byLens(calls)) {
|
package/dist/cli/review.js
CHANGED
|
@@ -24,7 +24,7 @@ import { buildPrompt, renderPrompt } from '../prompt.js';
|
|
|
24
24
|
import { deriveLensSet, extractBar } from '../practice-lenses.js';
|
|
25
25
|
import { buildFileContext, NO_FILE_CONTEXT_NOTE } from '../context-files.js';
|
|
26
26
|
import { buildReferences } from '../references.js';
|
|
27
|
-
import { renderFooter, tierLine, usageLines } from './footer.js';
|
|
27
|
+
import { promotedLine, renderFooter, tierLine, usageLines } from './footer.js';
|
|
28
28
|
/** Governance's pinned default, sent to both routes. */
|
|
29
29
|
export const DEFAULT_MODEL = 'claude-opus-5-5';
|
|
30
30
|
/** Governance's default output-token budget (billed route only). */
|
|
@@ -470,14 +470,25 @@ export async function main(argv, env, io, deps) {
|
|
|
470
470
|
dedup: parsed.dedup,
|
|
471
471
|
...(derived === undefined
|
|
472
472
|
? {}
|
|
473
|
-
: {
|
|
473
|
+
: {
|
|
474
|
+
tierSignals: derived.signals,
|
|
475
|
+
tierRejects: derived.rejects,
|
|
476
|
+
practiceIds: derived.practiceIds,
|
|
477
|
+
practiceNames: derived.practiceNames,
|
|
478
|
+
}),
|
|
474
479
|
}, (call) => void calls.push(call));
|
|
475
480
|
for (const line of usageLines(calls))
|
|
476
481
|
io.stderr(line);
|
|
477
482
|
const tier = tierLine(result.downgraded);
|
|
478
483
|
if (tier !== undefined)
|
|
479
484
|
io.stderr(`${tier}\n`);
|
|
480
|
-
const
|
|
485
|
+
const promotion = promotedLine(result.promoted);
|
|
486
|
+
if (promotion !== undefined)
|
|
487
|
+
io.stderr(`${promotion}\n`);
|
|
488
|
+
// Audit trail for tier decisions behind merged groups: stderr, not the report (see dedup.ts).
|
|
489
|
+
for (const line of result.dedupAudit)
|
|
490
|
+
io.stderr(line);
|
|
491
|
+
const footer = `\n\n${renderFooter(model, route, calls, result.downgraded, result.promoted)}`;
|
|
481
492
|
const note = derived !== undefined && derived.untagged.length > 0
|
|
482
493
|
? `\n\n> ${String(derived.untagged.length)} practice(s) had no "lens:" tag and were ` +
|
|
483
494
|
`not reviewed: ${derived.untagged.join(', ')}`
|
package/dist/dedup.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export type DedupResult = {
|
|
|
29
29
|
};
|
|
30
30
|
/** Ask the model to group `items`; members come back in their input order. */
|
|
31
31
|
export declare function dedupFindings(items: readonly NumberedFinding[], review: Reviewer): Promise<DedupResult>;
|
|
32
|
+
/**
|
|
33
|
+
* The audit trail of every MERGED group (two or more members): one stderr line per member, with
|
|
34
|
+
* its own lens id, tier and text, because the report shows only the highest-tier member's text.
|
|
35
|
+
* Singletons are not listed: the report already shows them as they are.
|
|
36
|
+
*/
|
|
37
|
+
export declare function dedupAuditLines(groups: readonly (readonly NumberedFinding[])[]): string[];
|
|
32
38
|
/**
|
|
33
39
|
* The report view of each group: the highest tier among its members, the text of its highest-tier member (the first blocking one, else the first),
|
|
34
40
|
* and who raised it, naming the practices of each lens that the members' text mentions (by id
|
package/dist/dedup.js
CHANGED
|
@@ -51,7 +51,8 @@ export function buildDedupPrompt(items) {
|
|
|
51
51
|
'Reply with ONLY a JSON array of arrays of ids, for example ' +
|
|
52
52
|
'[["a:0","b:1"],["a:1"],["b:0"]]. No prose, no markdown fence, no verdict line.',
|
|
53
53
|
].join('\n');
|
|
54
|
-
|
|
54
|
+
// `cache: false`: no other call reads this `shared`, so a cache write is pure cost.
|
|
55
|
+
return { shared, perLens, cache: false };
|
|
55
56
|
}
|
|
56
57
|
/** Parse and validate a reply: every one of `ids` in exactly one group. */
|
|
57
58
|
export function parseGroups(reply, ids) {
|
|
@@ -119,6 +120,23 @@ export async function dedupFindings(items, review) {
|
|
|
119
120
|
.sort((a, b) => (position.get(a[0]?.id ?? '') ?? 0) - (position.get(b[0]?.id ?? '') ?? 0));
|
|
120
121
|
return { ok: true, groups };
|
|
121
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* The audit trail of every MERGED group (two or more members): one stderr line per member, with
|
|
125
|
+
* its own lens id, tier and text, because the report shows only the highest-tier member's text.
|
|
126
|
+
* Singletons are not listed: the report already shows them as they are.
|
|
127
|
+
*/
|
|
128
|
+
export function dedupAuditLines(groups) {
|
|
129
|
+
const lines = [];
|
|
130
|
+
groups.forEach((members, index) => {
|
|
131
|
+
if (members.length < 2)
|
|
132
|
+
return;
|
|
133
|
+
for (const { id, finding } of members) {
|
|
134
|
+
const text = `${finding.summary} ${finding.detail ?? ''}`.replace(/\s+/g, ' ').trim();
|
|
135
|
+
lines.push(`dedup group ${String(index + 1)}: ${id} [${finding.blocking ? 'blocking' : 'smell'}] ${text}\n`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
122
140
|
/**
|
|
123
141
|
* The report view of each group: the highest tier among its members, the text of its highest-tier member (the first blocking one, else the first),
|
|
124
142
|
* and who raised it, naming the practices of each lens that the members' text mentions (by id
|
|
@@ -20,6 +20,10 @@ export interface DerivedLenses {
|
|
|
20
20
|
* practices' sensors (label stripped). A [blocking] finding must quote one of these.
|
|
21
21
|
*/
|
|
22
22
|
readonly signals: Readonly<Record<string, readonly string[]>>;
|
|
23
|
+
/** Per lens id: only the `_Reject:_` lines of `signals`. A smell quoting one is promoted. */
|
|
24
|
+
readonly rejects: Readonly<Record<string, readonly string[]>>;
|
|
25
|
+
/** Every practice id in the corpus (tagged or not): ignored when matching a quote. */
|
|
26
|
+
readonly practiceIds: readonly string[];
|
|
23
27
|
/** Per lens id: its practices' ids and H1 titles ('' when none), to name them in a report. */
|
|
24
28
|
readonly practiceNames: Readonly<Record<string, readonly {
|
|
25
29
|
readonly id: string;
|
|
@@ -60,6 +64,11 @@ export declare function extractSensorHalf(markdown: string): string | undefined;
|
|
|
60
64
|
* continuation lines (not a new bullet, heading or bold label) are joined onto their line.
|
|
61
65
|
*/
|
|
62
66
|
export declare function extractSignalLines(sensor: string): string[];
|
|
67
|
+
/** As `extractSignalLines`, keeping which label each line carried. */
|
|
68
|
+
export declare function extractLabelledSignals(sensor: string): {
|
|
69
|
+
readonly kind: 'reject' | 'expect';
|
|
70
|
+
readonly text: string;
|
|
71
|
+
}[];
|
|
63
72
|
/**
|
|
64
73
|
* Group practice files by their `lens:` value and build one lens per group.
|
|
65
74
|
* Files are taken in name order, a top-level README.md and non-.md files are
|
package/dist/practice-lenses.js
CHANGED
|
@@ -130,20 +130,25 @@ export function extractSensorHalf(markdown) {
|
|
|
130
130
|
return undefined;
|
|
131
131
|
return slice.trim();
|
|
132
132
|
}
|
|
133
|
-
const SIGNAL_LINE_RE = /^\s*(?:[-*+]\s+)?[_*]{1,2}(
|
|
133
|
+
const SIGNAL_LINE_RE = /^\s*(?:[-*+]\s+)?[_*]{1,2}(Reject|Expect):[_*]{1,2}\s*(.*)$/i;
|
|
134
134
|
const BLOCK_START_RE = /^\s*(?:[-*+]\s|\d+\.\s|#|\*\*)/;
|
|
135
135
|
/**
|
|
136
136
|
* The text of each `_Reject:_` / `_Expect:_` line in a sensor half, label stripped. Indented
|
|
137
137
|
* continuation lines (not a new bullet, heading or bold label) are joined onto their line.
|
|
138
138
|
*/
|
|
139
139
|
export function extractSignalLines(sensor) {
|
|
140
|
+
return extractLabelledSignals(sensor).map((s) => s.text);
|
|
141
|
+
}
|
|
142
|
+
/** As `extractSignalLines`, keeping which label each line carried. */
|
|
143
|
+
export function extractLabelledSignals(sensor) {
|
|
140
144
|
const out = [];
|
|
141
145
|
const lines = sensor.split(/\r?\n/);
|
|
142
146
|
for (let i = 0; i < lines.length; i++) {
|
|
143
147
|
const match = SIGNAL_LINE_RE.exec(lines[i] ?? '');
|
|
144
148
|
if (match === null)
|
|
145
149
|
continue;
|
|
146
|
-
|
|
150
|
+
const kind = (match[1] ?? '').toLowerCase() === 'reject' ? 'reject' : 'expect';
|
|
151
|
+
let text = (match[2] ?? '').trim();
|
|
147
152
|
while (i + 1 < lines.length) {
|
|
148
153
|
const next = lines[i + 1] ?? '';
|
|
149
154
|
if (next.trim() === '' || !/^\s+\S/.test(next) || BLOCK_START_RE.test(next))
|
|
@@ -152,7 +157,7 @@ export function extractSignalLines(sensor) {
|
|
|
152
157
|
i++;
|
|
153
158
|
}
|
|
154
159
|
if (text !== '')
|
|
155
|
-
out.push(text);
|
|
160
|
+
out.push({ kind, text });
|
|
156
161
|
}
|
|
157
162
|
return out;
|
|
158
163
|
}
|
|
@@ -169,11 +174,14 @@ export function deriveLensSet(files) {
|
|
|
169
174
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
170
175
|
const byLens = new Map();
|
|
171
176
|
const signals = {};
|
|
177
|
+
const rejects = {};
|
|
178
|
+
const practiceIds = [];
|
|
172
179
|
const practiceNames = {};
|
|
173
180
|
const untagged = [];
|
|
174
181
|
const missingSensor = [];
|
|
175
182
|
for (const file of eligible) {
|
|
176
183
|
const id = file.name.slice(0, -'.md'.length);
|
|
184
|
+
practiceIds.push(id);
|
|
177
185
|
const lensId = parseLensFrontmatter(file.text);
|
|
178
186
|
if (lensId === undefined) {
|
|
179
187
|
untagged.push(id);
|
|
@@ -186,7 +194,9 @@ export function deriveLensSet(files) {
|
|
|
186
194
|
const title = parseTitle(file.text);
|
|
187
195
|
group.push({ id, title, sensor });
|
|
188
196
|
byLens.set(lensId, group);
|
|
189
|
-
|
|
197
|
+
const labelled = sensor === undefined ? [] : extractLabelledSignals(sensor);
|
|
198
|
+
(signals[lensId] ??= []).push(...labelled.map((s) => s.text));
|
|
199
|
+
(rejects[lensId] ??= []).push(...labelled.filter((s) => s.kind === 'reject').map((s) => s.text));
|
|
190
200
|
(practiceNames[lensId] ??= []).push({ id, title: title ?? '' });
|
|
191
201
|
}
|
|
192
202
|
const lensIds = [...byLens.keys()].sort();
|
|
@@ -213,6 +223,8 @@ export function deriveLensSet(files) {
|
|
|
213
223
|
untagged,
|
|
214
224
|
missingSensor,
|
|
215
225
|
signals,
|
|
226
|
+
rejects,
|
|
227
|
+
practiceIds,
|
|
216
228
|
practiceNames,
|
|
217
229
|
};
|
|
218
230
|
}
|
package/dist/prompt.d.ts
CHANGED
|
@@ -94,6 +94,9 @@ export interface Prompt {
|
|
|
94
94
|
readonly shared: string;
|
|
95
95
|
/** The only part that varies by lens. Goes last. */
|
|
96
96
|
readonly perLens: string;
|
|
97
|
+
/** `false` for a call whose `shared` no other call reads (the dedup call): a transport that
|
|
98
|
+
* marks `shared` cacheable must not, as the cache write would never be read. */
|
|
99
|
+
readonly cache?: false;
|
|
97
100
|
}
|
|
98
101
|
/** The two halves in the one order that preserves the shared prefix. */
|
|
99
102
|
export declare function renderPrompt(prompt: Prompt): string;
|
package/dist/report.js
CHANGED
|
@@ -49,7 +49,9 @@ function verdictHeading(lens, verdict) {
|
|
|
49
49
|
if (result === undefined)
|
|
50
50
|
return lens;
|
|
51
51
|
const reason = result.reason === undefined ? '' : result.reason.trim();
|
|
52
|
-
|
|
52
|
+
// The model's reason was written for the outcome it gave, not the one tier enforcement left.
|
|
53
|
+
const word = result.tierAdjusted === true ? `${result.outcome} (after tier enforcement)` : result.outcome;
|
|
54
|
+
return reason === '' ? `${lens}: ${word}` : `${lens}: ${word}: ${reason}`;
|
|
53
55
|
}
|
|
54
56
|
export function renderReport(verdict, context) {
|
|
55
57
|
const lines = [
|
|
@@ -150,17 +152,19 @@ export function renderReport(verdict, context) {
|
|
|
150
152
|
'the reason each lens gave.', '');
|
|
151
153
|
}
|
|
152
154
|
if (verdict.outcome === 'approved') {
|
|
155
|
+
// What the reader was shown: de-duplicated groups when the findings were merged.
|
|
156
|
+
const shownSmells = dedup === undefined ? smellCount : dedup.groups.filter((g) => !g.blocking).length;
|
|
153
157
|
const base = verdict.outOfScope.length > 0
|
|
154
|
-
?
|
|
158
|
+
? shownSmells > 0
|
|
155
159
|
? 'Every lens with something in scope reviewed it. Nothing it found held this up. See ' +
|
|
156
160
|
'above for the lens(es) that had nothing in scope for this change.'
|
|
157
161
|
: 'Every lens with something in scope reviewed it and found nothing. See above for the ' +
|
|
158
162
|
'lens(es) that had nothing in scope for this change.'
|
|
159
|
-
:
|
|
163
|
+
: shownSmells > 0
|
|
160
164
|
? 'Every lens reviewed. Nothing it found held this up.'
|
|
161
165
|
: 'Every lens reviewed and none had a finding.';
|
|
162
|
-
const suffix =
|
|
163
|
-
? ` ${
|
|
166
|
+
const suffix = shownSmells > 0
|
|
167
|
+
? ` ${shownSmells} non-blocking finding(s) are listed above; none of them held this up. ` +
|
|
164
168
|
'If this merges as-is, file each as a follow-up issue before merging, referenced ' +
|
|
165
169
|
'as `owner/repo#123`.'
|
|
166
170
|
: '';
|
|
@@ -100,7 +100,13 @@ export function anthropicReviewer(config, post, onResolvedModel) {
|
|
|
100
100
|
// The shared half, marked cacheable — `Prompt`'s ordering contract made
|
|
101
101
|
// real: identical bytes for every lens, sent first, so a five-lens
|
|
102
102
|
// panel pays for the diff once.
|
|
103
|
-
|
|
103
|
+
// Unless the prompt says nothing else reads it (`cache: false`): a write nobody reads
|
|
104
|
+
// is pure cost.
|
|
105
|
+
system: [
|
|
106
|
+
prompt.cache === false
|
|
107
|
+
? { type: 'text', text: prompt.shared }
|
|
108
|
+
: { type: 'text', text: prompt.shared, cache_control: { type: 'ephemeral' } },
|
|
109
|
+
],
|
|
104
110
|
messages: [{ role: 'user', content: prompt.perLens }],
|
|
105
111
|
});
|
|
106
112
|
let response;
|
package/dist/run-review.d.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface ReviewNodeInput {
|
|
|
41
41
|
/** Per lens id: the signal lines its [blocking] findings must quote (see tier.ts). A lens
|
|
42
42
|
* with no entry is left as it was. Absent: no tier enforcement. */
|
|
43
43
|
readonly tierSignals?: Readonly<Record<string, readonly string[]>>;
|
|
44
|
+
/** Per lens id: just the `_Reject:_` lines of `tierSignals`; a smell quoting one is promoted. */
|
|
45
|
+
readonly tierRejects?: Readonly<Record<string, readonly string[]>>;
|
|
46
|
+
/** Every practice id in the corpus; ignored when matching a quote (see tier.ts). */
|
|
47
|
+
readonly practiceIds?: readonly string[];
|
|
44
48
|
/** After the panel (and tier enforcement), make ONE extra call through the same reviewer to
|
|
45
49
|
* group findings that describe the same defect (see dedup.ts). Off when absent. */
|
|
46
50
|
readonly dedup?: boolean;
|
|
@@ -60,5 +64,9 @@ export interface ReviewNodeResult {
|
|
|
60
64
|
readonly prompts: ReadonlyMap<string, Prompt>;
|
|
61
65
|
/** How many [blocking] findings tier enforcement downgraded to smells (0 when none). */
|
|
62
66
|
readonly downgraded: number;
|
|
67
|
+
/** How many smells tier enforcement promoted to [blocking] (0 when none). */
|
|
68
|
+
readonly promoted: number;
|
|
69
|
+
/** Stderr lines, one per member of every merged dedup group (empty when none merged). */
|
|
70
|
+
readonly dedupAudit: readonly string[];
|
|
63
71
|
}
|
|
64
72
|
export declare function runReviewNode(input: ReviewNodeInput, onCall?: (call: RawCall) => void): Promise<ReviewNodeResult>;
|
package/dist/run-review.js
CHANGED
|
@@ -36,7 +36,7 @@ import { describeThrown } from './raw-call.js';
|
|
|
36
36
|
import { normaliseReviewerReply } from './panel.js';
|
|
37
37
|
import { warmFirst } from './warm-first.js';
|
|
38
38
|
import { enforceTier } from './tier.js';
|
|
39
|
-
import { dedupFindings, numberFindings, toGroupViews } from './dedup.js';
|
|
39
|
+
import { dedupAuditLines, dedupFindings, numberFindings, toGroupViews } from './dedup.js';
|
|
40
40
|
// RECORD-NEVER-PREDICT discipline: `onCall` is OPTIONAL so every existing
|
|
41
41
|
// call site, and every test that builds a ReviewNodeInput by hand, is
|
|
42
42
|
// unaffected by this addition.
|
|
@@ -116,17 +116,22 @@ export async function runReviewNode(input, onCall) {
|
|
|
116
116
|
const prompts = panel.prompts;
|
|
117
117
|
// C1 runs before C2: de-duplication sees the tiers as enforced.
|
|
118
118
|
const tiered = input.tierSignals === undefined
|
|
119
|
-
? { verdict: panel.verdict, downgraded: 0 }
|
|
120
|
-
: enforceTier(panel.verdict, input.tierSignals
|
|
119
|
+
? { verdict: panel.verdict, downgraded: 0, promoted: 0 }
|
|
120
|
+
: enforceTier(panel.verdict, input.tierSignals, {
|
|
121
|
+
...(input.tierRejects === undefined ? {} : { rejects: input.tierRejects }),
|
|
122
|
+
...(input.practiceIds === undefined ? {} : { practiceIds: input.practiceIds }),
|
|
123
|
+
});
|
|
121
124
|
const verdict = tiered.verdict;
|
|
122
125
|
let dedup;
|
|
123
126
|
let dedupSkipped;
|
|
127
|
+
let dedupAudit = [];
|
|
124
128
|
if (input.dedup === true && verdict.findings.length >= 2) {
|
|
125
129
|
const numbered = numberFindings(verdict.findings);
|
|
126
130
|
// The observed reviewer, outside warm-first: the extra call is recorded like a lens call.
|
|
127
131
|
const outcome = await dedupFindings(numbered, observedReviewer);
|
|
128
132
|
if (outcome.ok) {
|
|
129
133
|
dedup = { groups: toGroupViews(outcome.groups, input.practiceNames), total: numbered.length };
|
|
134
|
+
dedupAudit = dedupAuditLines(outcome.groups);
|
|
130
135
|
}
|
|
131
136
|
else {
|
|
132
137
|
dedupSkipped = outcome.reason;
|
|
@@ -144,5 +149,7 @@ export async function runReviewNode(input, onCall) {
|
|
|
144
149
|
exitCode: exitCodeFor(verdict.outcome),
|
|
145
150
|
prompts,
|
|
146
151
|
downgraded: tiered.downgraded,
|
|
152
|
+
promoted: tiered.promoted,
|
|
153
|
+
dedupAudit,
|
|
147
154
|
};
|
|
148
155
|
}
|
package/dist/tier.d.ts
CHANGED
|
@@ -3,18 +3,33 @@ import type { Verdict } from './verdict.js';
|
|
|
3
3
|
export declare const MIN_QUOTE = 24;
|
|
4
4
|
/** Appended to the summary of a finding that was downgraded. */
|
|
5
5
|
export declare const DOWNGRADE_NOTE = " (downgraded: no signal quoted)";
|
|
6
|
+
/** Appended to the summary of a smell that was promoted. */
|
|
7
|
+
export declare const PROMOTE_NOTE = " (promoted: quotes a Reject signal)";
|
|
6
8
|
export declare function normaliseForQuote(text: string): string;
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Whether `text` contains a verbatim quote (per the rule above) of any of `signals`.
|
|
11
|
+
* `practiceIds` are the corpus's practice ids, which are ignored on both sides.
|
|
12
|
+
*/
|
|
13
|
+
export declare function quotesSignal(text: string, signals: readonly string[], practiceIds?: readonly string[]): boolean;
|
|
14
|
+
export interface TierContext {
|
|
15
|
+
/** Per lens id: just the `_Reject:_` lines. A lens with an entry can have smells promoted. */
|
|
16
|
+
readonly rejects?: Readonly<Record<string, readonly string[]>>;
|
|
17
|
+
/** Every practice id in the corpus, ignored when matching. */
|
|
18
|
+
readonly practiceIds?: readonly string[];
|
|
19
|
+
}
|
|
9
20
|
export interface TierResult {
|
|
10
21
|
readonly verdict: Verdict;
|
|
11
22
|
/** How many findings were downgraded. */
|
|
12
23
|
readonly downgraded: number;
|
|
24
|
+
/** How many smells were promoted to [blocking]. */
|
|
25
|
+
readonly promoted: number;
|
|
13
26
|
}
|
|
14
27
|
/**
|
|
15
|
-
* Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`,
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
28
|
+
* Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`, promote
|
|
29
|
+
* smells that quote one of the lens's own Reject lines (`context.rejects`), then re-aggregate.
|
|
30
|
+
* A lens that was `rejected` and is left with no [blocking] finding counts as `approved`; one
|
|
31
|
+
* that was `approved` and gained a promoted finding counts as `rejected`; either is marked
|
|
32
|
+
* `tierAdjusted`. Lenses without an entry (bundled lenses) are untouched. With nothing changed
|
|
33
|
+
* the verdict is returned as it was.
|
|
19
34
|
*/
|
|
20
|
-
export declare function enforceTier(verdict: Verdict, signals: Readonly<Record<string, readonly string[]
|
|
35
|
+
export declare function enforceTier(verdict: Verdict, signals: Readonly<Record<string, readonly string[]>>, context?: TierContext): TierResult;
|
package/dist/tier.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Enforces the tier in code. A derived lens is told to tag a finding [blocking] only when it
|
|
2
2
|
// quotes the signal it fails, but a model honours that loosely. After parsing, every [blocking]
|
|
3
3
|
// finding of a derived lens is checked against THAT lens's own practices' `_Reject:_` /
|
|
4
|
-
// `_Expect:_` lines; one that quotes none is downgraded to [smell]
|
|
4
|
+
// `_Expect:_` lines; one that quotes none is downgraded to [smell]. The mirror: a [smell]
|
|
5
|
+
// finding that quotes one of the lens's own `_Reject:_` lines (not `_Expect:_`) is promoted to
|
|
6
|
+
// [blocking], because the bar says a Reject blocks anywhere. The lens's outcome is then
|
|
5
7
|
// recomputed. Deterministic: no model call.
|
|
6
8
|
//
|
|
7
9
|
// MATCHING RULE. Both sides are normalised (lower-cased; markdown emphasis `*` `_` and
|
|
@@ -9,11 +11,28 @@
|
|
|
9
11
|
// finding (summary + detail) quotes a signal line when it contains a contiguous
|
|
10
12
|
// MIN_QUOTE-character (24) run of the signal's normalised text, or the whole normalised line
|
|
11
13
|
// when the line is shorter than that.
|
|
14
|
+
//
|
|
15
|
+
// PRACTICE IDS DO NOT COUNT AS QUOTING. Lens signal lines cross-reference other practices by id
|
|
16
|
+
// (a Reject line may say "see complexity-matches-the-problem"), so a finding that merely NAMES
|
|
17
|
+
// that id would otherwise match 24+ characters without quoting anything. Before matching, every
|
|
18
|
+
// practice id of the corpus (and any backticked kebab-case token) is replaced, in BOTH the
|
|
19
|
+
// signal line and the finding, by a break mark. A run of 24 characters can then never span or
|
|
20
|
+
// consist of an id, and a signal line that is only ids has nothing left to quote. Chosen over
|
|
21
|
+
// "the run must contain a non-id word": stripping is one rule applied to both sides, and it
|
|
22
|
+
// cannot be satisfied by an id plus one filler word.
|
|
23
|
+
//
|
|
24
|
+
// WORD BOUNDARIES. After normalising, every run of characters that are not letters or digits
|
|
25
|
+
// becomes one space, and both sides are padded with a space at each end. So the start or end of
|
|
26
|
+
// a text, and punctuation beside a word, all read as the same single space: a phrase at the
|
|
27
|
+
// start of a finding, after "(", or before ":" matches as it would after a space. The
|
|
28
|
+
// 24-character bar is unchanged; "unpinned/unremarked" is still not "unpinned and unremarked".
|
|
12
29
|
import { aggregate } from './verdict.js';
|
|
13
30
|
/** The shortest verbatim run of a signal line that counts as quoting it. */
|
|
14
31
|
export const MIN_QUOTE = 24;
|
|
15
32
|
/** Appended to the summary of a finding that was downgraded. */
|
|
16
33
|
export const DOWNGRADE_NOTE = ' (downgraded: no signal quoted)';
|
|
34
|
+
/** Appended to the summary of a smell that was promoted. */
|
|
35
|
+
export const PROMOTE_NOTE = ' (promoted: quotes a Reject signal)';
|
|
17
36
|
export function normaliseForQuote(text) {
|
|
18
37
|
return text
|
|
19
38
|
.replace(/[*_`]/g, '')
|
|
@@ -22,21 +41,59 @@ export function normaliseForQuote(text) {
|
|
|
22
41
|
.trim()
|
|
23
42
|
.toLowerCase();
|
|
24
43
|
}
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
/** Stands where an id was; survives normalisation and cannot occur in prose. */
|
|
45
|
+
const ID_MARK = '\u0001';
|
|
46
|
+
const BACKTICKED_ID = /`[a-z0-9]+(?:-[a-z0-9]+)+`/g;
|
|
47
|
+
function escapeRegExp(text) {
|
|
48
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
}
|
|
50
|
+
function idPattern(ids) {
|
|
51
|
+
const sorted = [...new Set(ids.map((id) => id.toLowerCase()).filter((id) => id !== ''))].sort((a, b) => b.length - a.length);
|
|
52
|
+
if (sorted.length === 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
return new RegExp(`(?<![a-z0-9-])(?:${sorted.map(escapeRegExp).join('|')})(?![a-z0-9-])`, 'g');
|
|
55
|
+
}
|
|
56
|
+
function markIds(text, pattern) {
|
|
57
|
+
const marked = text.toLowerCase().replace(BACKTICKED_ID, ID_MARK);
|
|
58
|
+
return pattern === undefined ? marked : marked.replace(pattern, ID_MARK);
|
|
59
|
+
}
|
|
60
|
+
/** Normalised, punctuation read as a space, padded with a space each end; '' when nothing is left. */
|
|
61
|
+
function boundaryForm(text) {
|
|
62
|
+
const spaced = normaliseForQuote(text)
|
|
63
|
+
.replace(/[^\p{L}\p{N}\u0001]+/gu, ' ')
|
|
64
|
+
.trim();
|
|
65
|
+
return spaced === '' ? '' : ` ${spaced} `;
|
|
66
|
+
}
|
|
67
|
+
/** A 24-character run of `needle` in `haystack`; a needle of 24 or fewer only whole, if allowed. */
|
|
68
|
+
function quotesRun(haystack, needle, wholeIfShort) {
|
|
69
|
+
if (needle === '')
|
|
70
|
+
return false;
|
|
71
|
+
if (needle.length <= MIN_QUOTE)
|
|
72
|
+
return wholeIfShort && haystack.includes(needle);
|
|
73
|
+
for (let i = 0; i + MIN_QUOTE <= needle.length; i++) {
|
|
74
|
+
if (haystack.includes(needle.slice(i, i + MIN_QUOTE)))
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether `text` contains a verbatim quote (per the rule above) of any of `signals`.
|
|
81
|
+
* `practiceIds` are the corpus's practice ids, which are ignored on both sides.
|
|
82
|
+
*/
|
|
83
|
+
export function quotesSignal(text, signals, practiceIds = []) {
|
|
84
|
+
const pattern = idPattern(practiceIds);
|
|
85
|
+
const haystack = boundaryForm(markIds(text, pattern));
|
|
28
86
|
for (const signal of signals) {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
87
|
+
const marked = markIds(signal, pattern);
|
|
88
|
+
if (marked.includes(ID_MARK)) {
|
|
89
|
+
// Each stretch between ids is quoted (or not) on its own; a short one never counts.
|
|
90
|
+
for (const stretch of marked.split(ID_MARK)) {
|
|
91
|
+
if (quotesRun(haystack, boundaryForm(stretch), false))
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
36
94
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return true;
|
|
95
|
+
else if (quotesRun(haystack, boundaryForm(marked), true)) {
|
|
96
|
+
return true;
|
|
40
97
|
}
|
|
41
98
|
}
|
|
42
99
|
return false;
|
|
@@ -50,39 +107,66 @@ function hasBlocking(findings) {
|
|
|
50
107
|
return findings.some((f) => f.blocking && !isNone(f));
|
|
51
108
|
}
|
|
52
109
|
/**
|
|
53
|
-
* Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`,
|
|
54
|
-
*
|
|
55
|
-
* `
|
|
56
|
-
*
|
|
110
|
+
* Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`, promote
|
|
111
|
+
* smells that quote one of the lens's own Reject lines (`context.rejects`), then re-aggregate.
|
|
112
|
+
* A lens that was `rejected` and is left with no [blocking] finding counts as `approved`; one
|
|
113
|
+
* that was `approved` and gained a promoted finding counts as `rejected`; either is marked
|
|
114
|
+
* `tierAdjusted`. Lenses without an entry (bundled lenses) are untouched. With nothing changed
|
|
115
|
+
* the verdict is returned as it was.
|
|
57
116
|
*/
|
|
58
|
-
export function enforceTier(verdict, signals) {
|
|
117
|
+
export function enforceTier(verdict, signals, context = {}) {
|
|
118
|
+
const ids = context.practiceIds ?? [];
|
|
59
119
|
let downgraded = 0;
|
|
120
|
+
let promoted = 0;
|
|
60
121
|
const results = verdict.results.map((result) => {
|
|
61
122
|
if (!Object.prototype.hasOwnProperty.call(signals, result.lens))
|
|
62
123
|
return result;
|
|
63
124
|
const own = signals[result.lens] ?? [];
|
|
125
|
+
const rejectLines = context.rejects !== undefined &&
|
|
126
|
+
Object.prototype.hasOwnProperty.call(context.rejects, result.lens)
|
|
127
|
+
? (context.rejects[result.lens] ?? [])
|
|
128
|
+
: undefined;
|
|
64
129
|
let changed = false;
|
|
130
|
+
let lensPromoted = 0;
|
|
65
131
|
const findings = result.findings.map((finding) => {
|
|
66
|
-
if (
|
|
132
|
+
if (isNone(finding))
|
|
67
133
|
return finding;
|
|
68
|
-
|
|
134
|
+
const text = `${finding.summary}\n${finding.detail ?? ''}`;
|
|
135
|
+
if (finding.blocking) {
|
|
136
|
+
if (quotesSignal(text, own, ids))
|
|
137
|
+
return finding;
|
|
138
|
+
changed = true;
|
|
139
|
+
downgraded++;
|
|
140
|
+
return { ...finding, blocking: false, summary: `${finding.summary}${DOWNGRADE_NOTE}` };
|
|
141
|
+
}
|
|
142
|
+
if (rejectLines === undefined || !quotesSignal(text, rejectLines, ids))
|
|
69
143
|
return finding;
|
|
70
144
|
changed = true;
|
|
71
|
-
|
|
72
|
-
|
|
145
|
+
promoted++;
|
|
146
|
+
lensPromoted++;
|
|
147
|
+
return { ...finding, blocking: true, summary: `${finding.summary}${PROMOTE_NOTE}` };
|
|
73
148
|
});
|
|
74
149
|
if (!changed)
|
|
75
150
|
return result;
|
|
76
151
|
const outcome = result.outcome === 'rejected' && !hasBlocking(findings)
|
|
77
152
|
? 'approved'
|
|
78
|
-
: result.outcome
|
|
79
|
-
|
|
153
|
+
: result.outcome === 'approved' && lensPromoted > 0
|
|
154
|
+
? 'rejected'
|
|
155
|
+
: result.outcome;
|
|
156
|
+
// The lens's own reason text is not regenerated, so a changed word must say who changed it.
|
|
157
|
+
return {
|
|
158
|
+
...result,
|
|
159
|
+
outcome,
|
|
160
|
+
findings,
|
|
161
|
+
...(outcome === result.outcome ? {} : { tierAdjusted: true }),
|
|
162
|
+
};
|
|
80
163
|
});
|
|
81
|
-
if (downgraded === 0)
|
|
82
|
-
return { verdict, downgraded };
|
|
164
|
+
if (downgraded === 0 && promoted === 0)
|
|
165
|
+
return { verdict, downgraded, promoted };
|
|
83
166
|
// Prior-round rulings are carried on each result; the CLI never supplies orphaned ones.
|
|
84
167
|
return {
|
|
85
168
|
verdict: aggregate(verdict.results.map((r) => r.lens), results),
|
|
86
169
|
downgraded,
|
|
170
|
+
promoted,
|
|
87
171
|
};
|
|
88
172
|
}
|
package/dist/verdict.d.ts
CHANGED
|
@@ -30,6 +30,9 @@ export interface LensResult {
|
|
|
30
30
|
/** Why this lens could not run, or why it found nothing in scope.
|
|
31
31
|
* Meaningless on `approved` and `rejected`. */
|
|
32
32
|
readonly reason?: string;
|
|
33
|
+
/** True when tier enforcement (tier.ts) changed this lens's outcome after the model gave it,
|
|
34
|
+
* so the model's own `reason` text no longer matches. Absent otherwise. */
|
|
35
|
+
readonly tierAdjusted?: boolean;
|
|
33
36
|
/**
|
|
34
37
|
* This lens's own rulings on whichever prior blocking findings were
|
|
35
38
|
* attributed to it (`Subject.priorFindings`, filtered by lens). Present
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@verevoir/review",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "The review panel: lens-based diff review. One implementation, consumed by pump-internal review, local preflight, eval-review, and CI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/cli/footer.ts
CHANGED
|
@@ -46,15 +46,25 @@ export function tierLine(downgraded: number): string | undefined {
|
|
|
46
46
|
: undefined;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** The one line saying how many smells tier enforcement promoted to [blocking]; none for 0. */
|
|
50
|
+
export function promotedLine(promoted: number): string | undefined {
|
|
51
|
+
return promoted > 0
|
|
52
|
+
? `Tier: ${String(promoted)} finding(s) promoted: quotes a Reject signal`
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
export function renderFooter(
|
|
50
57
|
requested: string,
|
|
51
58
|
route: RouteName,
|
|
52
59
|
calls: readonly RawCall[],
|
|
53
60
|
downgraded = 0,
|
|
61
|
+
promoted = 0,
|
|
54
62
|
): string {
|
|
55
63
|
const lines = ['---', '', `Judged with ${modelText(requested, calls)}, route: ${route}.`];
|
|
56
64
|
const tier = tierLine(downgraded);
|
|
57
65
|
if (tier !== undefined) lines.push('', tier);
|
|
66
|
+
const promotion = promotedLine(promoted);
|
|
67
|
+
if (promotion !== undefined) lines.push('', promotion);
|
|
58
68
|
if (calls.length > 0) {
|
|
59
69
|
lines.push('', 'Per-lens usage and wall time:', '');
|
|
60
70
|
for (const call of byLens(calls)) {
|
package/src/cli/review.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { buildFileContext, NO_FILE_CONTEXT_NOTE } from '../context-files.js';
|
|
|
31
31
|
import { buildReferences } from '../references.js';
|
|
32
32
|
import type { DirEntry } from '../references.js';
|
|
33
33
|
import type { RawCall } from '../raw-call.js';
|
|
34
|
-
import { renderFooter, tierLine, usageLines } from './footer.js';
|
|
34
|
+
import { promotedLine, renderFooter, tierLine, usageLines } from './footer.js';
|
|
35
35
|
import type { RouteName } from './footer.js';
|
|
36
36
|
import type { DerivedLenses } from '../practice-lenses.js';
|
|
37
37
|
|
|
@@ -592,14 +592,23 @@ export async function main(
|
|
|
592
592
|
dedup: parsed.dedup,
|
|
593
593
|
...(derived === undefined
|
|
594
594
|
? {}
|
|
595
|
-
: {
|
|
595
|
+
: {
|
|
596
|
+
tierSignals: derived.signals,
|
|
597
|
+
tierRejects: derived.rejects,
|
|
598
|
+
practiceIds: derived.practiceIds,
|
|
599
|
+
practiceNames: derived.practiceNames,
|
|
600
|
+
}),
|
|
596
601
|
},
|
|
597
602
|
(call) => void calls.push(call),
|
|
598
603
|
);
|
|
599
604
|
for (const line of usageLines(calls)) io.stderr(line);
|
|
600
605
|
const tier = tierLine(result.downgraded);
|
|
601
606
|
if (tier !== undefined) io.stderr(`${tier}\n`);
|
|
602
|
-
const
|
|
607
|
+
const promotion = promotedLine(result.promoted);
|
|
608
|
+
if (promotion !== undefined) io.stderr(`${promotion}\n`);
|
|
609
|
+
// Audit trail for tier decisions behind merged groups: stderr, not the report (see dedup.ts).
|
|
610
|
+
for (const line of result.dedupAudit) io.stderr(line);
|
|
611
|
+
const footer = `\n\n${renderFooter(model, route, calls, result.downgraded, result.promoted)}`;
|
|
603
612
|
const note =
|
|
604
613
|
derived !== undefined && derived.untagged.length > 0
|
|
605
614
|
? `\n\n> ${String(derived.untagged.length)} practice(s) had no "lens:" tag and were ` +
|
package/src/dedup.ts
CHANGED
|
@@ -66,7 +66,8 @@ export function buildDedupPrompt(items: readonly NumberedFinding[]): Prompt {
|
|
|
66
66
|
'Reply with ONLY a JSON array of arrays of ids, for example ' +
|
|
67
67
|
'[["a:0","b:1"],["a:1"],["b:0"]]. No prose, no markdown fence, no verdict line.',
|
|
68
68
|
].join('\n');
|
|
69
|
-
|
|
69
|
+
// `cache: false`: no other call reads this `shared`, so a cache write is pure cost.
|
|
70
|
+
return { shared, perLens, cache: false };
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
export type GroupsResult =
|
|
@@ -148,6 +149,25 @@ export async function dedupFindings(
|
|
|
148
149
|
return { ok: true, groups };
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The audit trail of every MERGED group (two or more members): one stderr line per member, with
|
|
154
|
+
* its own lens id, tier and text, because the report shows only the highest-tier member's text.
|
|
155
|
+
* Singletons are not listed: the report already shows them as they are.
|
|
156
|
+
*/
|
|
157
|
+
export function dedupAuditLines(groups: readonly (readonly NumberedFinding[])[]): string[] {
|
|
158
|
+
const lines: string[] = [];
|
|
159
|
+
groups.forEach((members, index) => {
|
|
160
|
+
if (members.length < 2) return;
|
|
161
|
+
for (const { id, finding } of members) {
|
|
162
|
+
const text = `${finding.summary} ${finding.detail ?? ''}`.replace(/\s+/g, ' ').trim();
|
|
163
|
+
lines.push(
|
|
164
|
+
`dedup group ${String(index + 1)}: ${id} [${finding.blocking ? 'blocking' : 'smell'}] ${text}\n`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
|
|
151
171
|
/**
|
|
152
172
|
* The report view of each group: the highest tier among its members, the text of its highest-tier member (the first blocking one, else the first),
|
|
153
173
|
* and who raised it, naming the practices of each lens that the members' text mentions (by id
|
package/src/practice-lenses.ts
CHANGED
|
@@ -47,6 +47,10 @@ export interface DerivedLenses {
|
|
|
47
47
|
* practices' sensors (label stripped). A [blocking] finding must quote one of these.
|
|
48
48
|
*/
|
|
49
49
|
readonly signals: Readonly<Record<string, readonly string[]>>;
|
|
50
|
+
/** Per lens id: only the `_Reject:_` lines of `signals`. A smell quoting one is promoted. */
|
|
51
|
+
readonly rejects: Readonly<Record<string, readonly string[]>>;
|
|
52
|
+
/** Every practice id in the corpus (tagged or not): ignored when matching a quote. */
|
|
53
|
+
readonly practiceIds: readonly string[];
|
|
50
54
|
/** Per lens id: its practices' ids and H1 titles ('' when none), to name them in a report. */
|
|
51
55
|
readonly practiceNames: Readonly<
|
|
52
56
|
Record<string, readonly { readonly id: string; readonly title: string }[]>
|
|
@@ -174,7 +178,7 @@ export function extractSensorHalf(markdown: string): string | undefined {
|
|
|
174
178
|
return slice.trim();
|
|
175
179
|
}
|
|
176
180
|
|
|
177
|
-
const SIGNAL_LINE_RE = /^\s*(?:[-*+]\s+)?[_*]{1,2}(
|
|
181
|
+
const SIGNAL_LINE_RE = /^\s*(?:[-*+]\s+)?[_*]{1,2}(Reject|Expect):[_*]{1,2}\s*(.*)$/i;
|
|
178
182
|
const BLOCK_START_RE = /^\s*(?:[-*+]\s|\d+\.\s|#|\*\*)/;
|
|
179
183
|
|
|
180
184
|
/**
|
|
@@ -182,19 +186,27 @@ const BLOCK_START_RE = /^\s*(?:[-*+]\s|\d+\.\s|#|\*\*)/;
|
|
|
182
186
|
* continuation lines (not a new bullet, heading or bold label) are joined onto their line.
|
|
183
187
|
*/
|
|
184
188
|
export function extractSignalLines(sensor: string): string[] {
|
|
185
|
-
|
|
189
|
+
return extractLabelledSignals(sensor).map((s) => s.text);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** As `extractSignalLines`, keeping which label each line carried. */
|
|
193
|
+
export function extractLabelledSignals(
|
|
194
|
+
sensor: string,
|
|
195
|
+
): { readonly kind: 'reject' | 'expect'; readonly text: string }[] {
|
|
196
|
+
const out: { kind: 'reject' | 'expect'; text: string }[] = [];
|
|
186
197
|
const lines = sensor.split(/\r?\n/);
|
|
187
198
|
for (let i = 0; i < lines.length; i++) {
|
|
188
199
|
const match = SIGNAL_LINE_RE.exec(lines[i] ?? '');
|
|
189
200
|
if (match === null) continue;
|
|
190
|
-
|
|
201
|
+
const kind = (match[1] ?? '').toLowerCase() === 'reject' ? 'reject' : 'expect';
|
|
202
|
+
let text = (match[2] ?? '').trim();
|
|
191
203
|
while (i + 1 < lines.length) {
|
|
192
204
|
const next = lines[i + 1] ?? '';
|
|
193
205
|
if (next.trim() === '' || !/^\s+\S/.test(next) || BLOCK_START_RE.test(next)) break;
|
|
194
206
|
text = `${text} ${next.trim()}`;
|
|
195
207
|
i++;
|
|
196
208
|
}
|
|
197
|
-
if (text !== '') out.push(text);
|
|
209
|
+
if (text !== '') out.push({ kind, text });
|
|
198
210
|
}
|
|
199
211
|
return out;
|
|
200
212
|
}
|
|
@@ -216,11 +228,14 @@ export function deriveLensSet(files: readonly PracticeFile[]): DerivedLenses {
|
|
|
216
228
|
{ id: string; title: string | undefined; sensor: string | undefined }[]
|
|
217
229
|
>();
|
|
218
230
|
const signals: Record<string, string[]> = {};
|
|
231
|
+
const rejects: Record<string, string[]> = {};
|
|
232
|
+
const practiceIds: string[] = [];
|
|
219
233
|
const practiceNames: Record<string, { id: string; title: string }[]> = {};
|
|
220
234
|
const untagged: string[] = [];
|
|
221
235
|
const missingSensor: string[] = [];
|
|
222
236
|
for (const file of eligible) {
|
|
223
237
|
const id = file.name.slice(0, -'.md'.length);
|
|
238
|
+
practiceIds.push(id);
|
|
224
239
|
const lensId = parseLensFrontmatter(file.text);
|
|
225
240
|
if (lensId === undefined) {
|
|
226
241
|
untagged.push(id);
|
|
@@ -232,7 +247,11 @@ export function deriveLensSet(files: readonly PracticeFile[]): DerivedLenses {
|
|
|
232
247
|
const title = parseTitle(file.text);
|
|
233
248
|
group.push({ id, title, sensor });
|
|
234
249
|
byLens.set(lensId, group);
|
|
235
|
-
|
|
250
|
+
const labelled = sensor === undefined ? [] : extractLabelledSignals(sensor);
|
|
251
|
+
(signals[lensId] ??= []).push(...labelled.map((s) => s.text));
|
|
252
|
+
(rejects[lensId] ??= []).push(
|
|
253
|
+
...labelled.filter((s) => s.kind === 'reject').map((s) => s.text),
|
|
254
|
+
);
|
|
236
255
|
(practiceNames[lensId] ??= []).push({ id, title: title ?? '' });
|
|
237
256
|
}
|
|
238
257
|
|
|
@@ -260,6 +279,8 @@ export function deriveLensSet(files: readonly PracticeFile[]): DerivedLenses {
|
|
|
260
279
|
untagged,
|
|
261
280
|
missingSensor,
|
|
262
281
|
signals,
|
|
282
|
+
rejects,
|
|
283
|
+
practiceIds,
|
|
263
284
|
practiceNames,
|
|
264
285
|
};
|
|
265
286
|
}
|
package/src/prompt.ts
CHANGED
|
@@ -144,6 +144,9 @@ export interface Prompt {
|
|
|
144
144
|
readonly shared: string;
|
|
145
145
|
/** The only part that varies by lens. Goes last. */
|
|
146
146
|
readonly perLens: string;
|
|
147
|
+
/** `false` for a call whose `shared` no other call reads (the dedup call): a transport that
|
|
148
|
+
* marks `shared` cacheable must not, as the cache write would never be read. */
|
|
149
|
+
readonly cache?: false;
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
/** The two halves in the one order that preserves the shared prefix. */
|
package/src/report.ts
CHANGED
|
@@ -72,7 +72,10 @@ function verdictHeading(lens: string, verdict: Verdict): string {
|
|
|
72
72
|
const result = verdict.results.find((r) => r.lens === lens);
|
|
73
73
|
if (result === undefined) return lens;
|
|
74
74
|
const reason = result.reason === undefined ? '' : result.reason.trim();
|
|
75
|
-
|
|
75
|
+
// The model's reason was written for the outcome it gave, not the one tier enforcement left.
|
|
76
|
+
const word =
|
|
77
|
+
result.tierAdjusted === true ? `${result.outcome} (after tier enforcement)` : result.outcome;
|
|
78
|
+
return reason === '' ? `${lens}: ${word}` : `${lens}: ${word}: ${reason}`;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
export function renderReport(verdict: Verdict, context: ReportContext): string {
|
|
@@ -204,19 +207,22 @@ export function renderReport(verdict: Verdict, context: ReportContext): string {
|
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
if (verdict.outcome === 'approved') {
|
|
210
|
+
// What the reader was shown: de-duplicated groups when the findings were merged.
|
|
211
|
+
const shownSmells =
|
|
212
|
+
dedup === undefined ? smellCount : dedup.groups.filter((g) => !g.blocking).length;
|
|
207
213
|
const base =
|
|
208
214
|
verdict.outOfScope.length > 0
|
|
209
|
-
?
|
|
215
|
+
? shownSmells > 0
|
|
210
216
|
? 'Every lens with something in scope reviewed it. Nothing it found held this up. See ' +
|
|
211
217
|
'above for the lens(es) that had nothing in scope for this change.'
|
|
212
218
|
: 'Every lens with something in scope reviewed it and found nothing. See above for the ' +
|
|
213
219
|
'lens(es) that had nothing in scope for this change.'
|
|
214
|
-
:
|
|
220
|
+
: shownSmells > 0
|
|
215
221
|
? 'Every lens reviewed. Nothing it found held this up.'
|
|
216
222
|
: 'Every lens reviewed and none had a finding.';
|
|
217
223
|
const suffix =
|
|
218
|
-
|
|
219
|
-
? ` ${
|
|
224
|
+
shownSmells > 0
|
|
225
|
+
? ` ${shownSmells} non-blocking finding(s) are listed above; none of them held this up. ` +
|
|
220
226
|
'If this merges as-is, file each as a follow-up issue before merging, referenced ' +
|
|
221
227
|
'as `owner/repo#123`.'
|
|
222
228
|
: '';
|
|
@@ -188,7 +188,13 @@ export function anthropicReviewer(
|
|
|
188
188
|
// The shared half, marked cacheable — `Prompt`'s ordering contract made
|
|
189
189
|
// real: identical bytes for every lens, sent first, so a five-lens
|
|
190
190
|
// panel pays for the diff once.
|
|
191
|
-
|
|
191
|
+
// Unless the prompt says nothing else reads it (`cache: false`): a write nobody reads
|
|
192
|
+
// is pure cost.
|
|
193
|
+
system: [
|
|
194
|
+
prompt.cache === false
|
|
195
|
+
? { type: 'text', text: prompt.shared }
|
|
196
|
+
: { type: 'text', text: prompt.shared, cache_control: { type: 'ephemeral' } },
|
|
197
|
+
],
|
|
192
198
|
messages: [{ role: 'user', content: prompt.perLens }],
|
|
193
199
|
});
|
|
194
200
|
|
package/src/run-review.ts
CHANGED
|
@@ -42,7 +42,7 @@ import { describeThrown, type RawCall } from './raw-call.js';
|
|
|
42
42
|
import { normaliseReviewerReply, type Reviewer } from './panel.js';
|
|
43
43
|
import { warmFirst } from './warm-first.js';
|
|
44
44
|
import { enforceTier } from './tier.js';
|
|
45
|
-
import { dedupFindings, numberFindings, toGroupViews } from './dedup.js';
|
|
45
|
+
import { dedupAuditLines, dedupFindings, numberFindings, toGroupViews } from './dedup.js';
|
|
46
46
|
import type { DedupGroupView } from './report.js';
|
|
47
47
|
|
|
48
48
|
export type { ReviewAccount } from './review-account.js';
|
|
@@ -83,6 +83,10 @@ export interface ReviewNodeInput {
|
|
|
83
83
|
/** Per lens id: the signal lines its [blocking] findings must quote (see tier.ts). A lens
|
|
84
84
|
* with no entry is left as it was. Absent: no tier enforcement. */
|
|
85
85
|
readonly tierSignals?: Readonly<Record<string, readonly string[]>>;
|
|
86
|
+
/** Per lens id: just the `_Reject:_` lines of `tierSignals`; a smell quoting one is promoted. */
|
|
87
|
+
readonly tierRejects?: Readonly<Record<string, readonly string[]>>;
|
|
88
|
+
/** Every practice id in the corpus; ignored when matching a quote (see tier.ts). */
|
|
89
|
+
readonly practiceIds?: readonly string[];
|
|
86
90
|
/** After the panel (and tier enforcement), make ONE extra call through the same reviewer to
|
|
87
91
|
* group findings that describe the same defect (see dedup.ts). Off when absent. */
|
|
88
92
|
readonly dedup?: boolean;
|
|
@@ -100,6 +104,10 @@ export interface ReviewNodeResult {
|
|
|
100
104
|
readonly prompts: ReadonlyMap<string, Prompt>;
|
|
101
105
|
/** How many [blocking] findings tier enforcement downgraded to smells (0 when none). */
|
|
102
106
|
readonly downgraded: number;
|
|
107
|
+
/** How many smells tier enforcement promoted to [blocking] (0 when none). */
|
|
108
|
+
readonly promoted: number;
|
|
109
|
+
/** Stderr lines, one per member of every merged dedup group (empty when none merged). */
|
|
110
|
+
readonly dedupAudit: readonly string[];
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
// RECORD-NEVER-PREDICT discipline: `onCall` is OPTIONAL so every existing
|
|
@@ -190,18 +198,23 @@ export async function runReviewNode(
|
|
|
190
198
|
// C1 runs before C2: de-duplication sees the tiers as enforced.
|
|
191
199
|
const tiered =
|
|
192
200
|
input.tierSignals === undefined
|
|
193
|
-
? { verdict: panel.verdict, downgraded: 0 }
|
|
194
|
-
: enforceTier(panel.verdict, input.tierSignals
|
|
201
|
+
? { verdict: panel.verdict, downgraded: 0, promoted: 0 }
|
|
202
|
+
: enforceTier(panel.verdict, input.tierSignals, {
|
|
203
|
+
...(input.tierRejects === undefined ? {} : { rejects: input.tierRejects }),
|
|
204
|
+
...(input.practiceIds === undefined ? {} : { practiceIds: input.practiceIds }),
|
|
205
|
+
});
|
|
195
206
|
const verdict = tiered.verdict;
|
|
196
207
|
|
|
197
208
|
let dedup: { groups: DedupGroupView[]; total: number } | undefined;
|
|
198
209
|
let dedupSkipped: string | undefined;
|
|
210
|
+
let dedupAudit: readonly string[] = [];
|
|
199
211
|
if (input.dedup === true && verdict.findings.length >= 2) {
|
|
200
212
|
const numbered = numberFindings(verdict.findings);
|
|
201
213
|
// The observed reviewer, outside warm-first: the extra call is recorded like a lens call.
|
|
202
214
|
const outcome = await dedupFindings(numbered, observedReviewer);
|
|
203
215
|
if (outcome.ok) {
|
|
204
216
|
dedup = { groups: toGroupViews(outcome.groups, input.practiceNames), total: numbered.length };
|
|
217
|
+
dedupAudit = dedupAuditLines(outcome.groups);
|
|
205
218
|
} else {
|
|
206
219
|
dedupSkipped = outcome.reason;
|
|
207
220
|
}
|
|
@@ -220,5 +233,7 @@ export async function runReviewNode(
|
|
|
220
233
|
exitCode: exitCodeFor(verdict.outcome),
|
|
221
234
|
prompts,
|
|
222
235
|
downgraded: tiered.downgraded,
|
|
236
|
+
promoted: tiered.promoted,
|
|
237
|
+
dedupAudit,
|
|
223
238
|
};
|
|
224
239
|
}
|
package/src/tier.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Enforces the tier in code. A derived lens is told to tag a finding [blocking] only when it
|
|
2
2
|
// quotes the signal it fails, but a model honours that loosely. After parsing, every [blocking]
|
|
3
3
|
// finding of a derived lens is checked against THAT lens's own practices' `_Reject:_` /
|
|
4
|
-
// `_Expect:_` lines; one that quotes none is downgraded to [smell]
|
|
4
|
+
// `_Expect:_` lines; one that quotes none is downgraded to [smell]. The mirror: a [smell]
|
|
5
|
+
// finding that quotes one of the lens's own `_Reject:_` lines (not `_Expect:_`) is promoted to
|
|
6
|
+
// [blocking], because the bar says a Reject blocks anywhere. The lens's outcome is then
|
|
5
7
|
// recomputed. Deterministic: no model call.
|
|
6
8
|
//
|
|
7
9
|
// MATCHING RULE. Both sides are normalised (lower-cased; markdown emphasis `*` `_` and
|
|
@@ -9,6 +11,21 @@
|
|
|
9
11
|
// finding (summary + detail) quotes a signal line when it contains a contiguous
|
|
10
12
|
// MIN_QUOTE-character (24) run of the signal's normalised text, or the whole normalised line
|
|
11
13
|
// when the line is shorter than that.
|
|
14
|
+
//
|
|
15
|
+
// PRACTICE IDS DO NOT COUNT AS QUOTING. Lens signal lines cross-reference other practices by id
|
|
16
|
+
// (a Reject line may say "see complexity-matches-the-problem"), so a finding that merely NAMES
|
|
17
|
+
// that id would otherwise match 24+ characters without quoting anything. Before matching, every
|
|
18
|
+
// practice id of the corpus (and any backticked kebab-case token) is replaced, in BOTH the
|
|
19
|
+
// signal line and the finding, by a break mark. A run of 24 characters can then never span or
|
|
20
|
+
// consist of an id, and a signal line that is only ids has nothing left to quote. Chosen over
|
|
21
|
+
// "the run must contain a non-id word": stripping is one rule applied to both sides, and it
|
|
22
|
+
// cannot be satisfied by an id plus one filler word.
|
|
23
|
+
//
|
|
24
|
+
// WORD BOUNDARIES. After normalising, every run of characters that are not letters or digits
|
|
25
|
+
// becomes one space, and both sides are padded with a space at each end. So the start or end of
|
|
26
|
+
// a text, and punctuation beside a word, all read as the same single space: a phrase at the
|
|
27
|
+
// start of a finding, after "(", or before ":" matches as it would after a space. The
|
|
28
|
+
// 24-character bar is unchanged; "unpinned/unremarked" is still not "unpinned and unremarked".
|
|
12
29
|
|
|
13
30
|
import { aggregate } from './verdict.js';
|
|
14
31
|
import type { Finding, LensResult, Verdict } from './verdict.js';
|
|
@@ -19,6 +36,9 @@ export const MIN_QUOTE = 24;
|
|
|
19
36
|
/** Appended to the summary of a finding that was downgraded. */
|
|
20
37
|
export const DOWNGRADE_NOTE = ' (downgraded: no signal quoted)';
|
|
21
38
|
|
|
39
|
+
/** Appended to the summary of a smell that was promoted. */
|
|
40
|
+
export const PROMOTE_NOTE = ' (promoted: quotes a Reject signal)';
|
|
41
|
+
|
|
22
42
|
export function normaliseForQuote(text: string): string {
|
|
23
43
|
return text
|
|
24
44
|
.replace(/[*_`]/g, '')
|
|
@@ -28,18 +48,65 @@ export function normaliseForQuote(text: string): string {
|
|
|
28
48
|
.toLowerCase();
|
|
29
49
|
}
|
|
30
50
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
/** Stands where an id was; survives normalisation and cannot occur in prose. */
|
|
52
|
+
const ID_MARK = '\u0001';
|
|
53
|
+
const BACKTICKED_ID = /`[a-z0-9]+(?:-[a-z0-9]+)+`/g;
|
|
54
|
+
|
|
55
|
+
function escapeRegExp(text: string): string {
|
|
56
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function idPattern(ids: readonly string[]): RegExp | undefined {
|
|
60
|
+
const sorted = [...new Set(ids.map((id) => id.toLowerCase()).filter((id) => id !== ''))].sort(
|
|
61
|
+
(a, b) => b.length - a.length,
|
|
62
|
+
);
|
|
63
|
+
if (sorted.length === 0) return undefined;
|
|
64
|
+
return new RegExp(`(?<![a-z0-9-])(?:${sorted.map(escapeRegExp).join('|')})(?![a-z0-9-])`, 'g');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function markIds(text: string, pattern: RegExp | undefined): string {
|
|
68
|
+
const marked = text.toLowerCase().replace(BACKTICKED_ID, ID_MARK);
|
|
69
|
+
return pattern === undefined ? marked : marked.replace(pattern, ID_MARK);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Normalised, punctuation read as a space, padded with a space each end; '' when nothing is left. */
|
|
73
|
+
function boundaryForm(text: string): string {
|
|
74
|
+
const spaced = normaliseForQuote(text)
|
|
75
|
+
.replace(/[^\p{L}\p{N}\u0001]+/gu, ' ')
|
|
76
|
+
.trim();
|
|
77
|
+
return spaced === '' ? '' : ` ${spaced} `;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A 24-character run of `needle` in `haystack`; a needle of 24 or fewer only whole, if allowed. */
|
|
81
|
+
function quotesRun(haystack: string, needle: string, wholeIfShort: boolean): boolean {
|
|
82
|
+
if (needle === '') return false;
|
|
83
|
+
if (needle.length <= MIN_QUOTE) return wholeIfShort && haystack.includes(needle);
|
|
84
|
+
for (let i = 0; i + MIN_QUOTE <= needle.length; i++) {
|
|
85
|
+
if (haystack.includes(needle.slice(i, i + MIN_QUOTE))) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether `text` contains a verbatim quote (per the rule above) of any of `signals`.
|
|
92
|
+
* `practiceIds` are the corpus's practice ids, which are ignored on both sides.
|
|
93
|
+
*/
|
|
94
|
+
export function quotesSignal(
|
|
95
|
+
text: string,
|
|
96
|
+
signals: readonly string[],
|
|
97
|
+
practiceIds: readonly string[] = [],
|
|
98
|
+
): boolean {
|
|
99
|
+
const pattern = idPattern(practiceIds);
|
|
100
|
+
const haystack = boundaryForm(markIds(text, pattern));
|
|
34
101
|
for (const signal of signals) {
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
102
|
+
const marked = markIds(signal, pattern);
|
|
103
|
+
if (marked.includes(ID_MARK)) {
|
|
104
|
+
// Each stretch between ids is quoted (or not) on its own; a short one never counts.
|
|
105
|
+
for (const stretch of marked.split(ID_MARK)) {
|
|
106
|
+
if (quotesRun(haystack, boundaryForm(stretch), false)) return true;
|
|
107
|
+
}
|
|
108
|
+
} else if (quotesRun(haystack, boundaryForm(marked), true)) {
|
|
109
|
+
return true;
|
|
43
110
|
}
|
|
44
111
|
}
|
|
45
112
|
return false;
|
|
@@ -56,42 +123,78 @@ function hasBlocking(findings: readonly Finding[]): boolean {
|
|
|
56
123
|
return findings.some((f) => f.blocking && !isNone(f));
|
|
57
124
|
}
|
|
58
125
|
|
|
126
|
+
export interface TierContext {
|
|
127
|
+
/** Per lens id: just the `_Reject:_` lines. A lens with an entry can have smells promoted. */
|
|
128
|
+
readonly rejects?: Readonly<Record<string, readonly string[]>>;
|
|
129
|
+
/** Every practice id in the corpus, ignored when matching. */
|
|
130
|
+
readonly practiceIds?: readonly string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
59
133
|
export interface TierResult {
|
|
60
134
|
readonly verdict: Verdict;
|
|
61
135
|
/** How many findings were downgraded. */
|
|
62
136
|
readonly downgraded: number;
|
|
137
|
+
/** How many smells were promoted to [blocking]. */
|
|
138
|
+
readonly promoted: number;
|
|
63
139
|
}
|
|
64
140
|
|
|
65
141
|
/**
|
|
66
|
-
* Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`,
|
|
67
|
-
*
|
|
68
|
-
* `
|
|
69
|
-
*
|
|
142
|
+
* Downgrade unquoted [blocking] findings of every lens that has an entry in `signals`, promote
|
|
143
|
+
* smells that quote one of the lens's own Reject lines (`context.rejects`), then re-aggregate.
|
|
144
|
+
* A lens that was `rejected` and is left with no [blocking] finding counts as `approved`; one
|
|
145
|
+
* that was `approved` and gained a promoted finding counts as `rejected`; either is marked
|
|
146
|
+
* `tierAdjusted`. Lenses without an entry (bundled lenses) are untouched. With nothing changed
|
|
147
|
+
* the verdict is returned as it was.
|
|
70
148
|
*/
|
|
71
149
|
export function enforceTier(
|
|
72
150
|
verdict: Verdict,
|
|
73
151
|
signals: Readonly<Record<string, readonly string[]>>,
|
|
152
|
+
context: TierContext = {},
|
|
74
153
|
): TierResult {
|
|
154
|
+
const ids = context.practiceIds ?? [];
|
|
75
155
|
let downgraded = 0;
|
|
156
|
+
let promoted = 0;
|
|
76
157
|
const results = verdict.results.map((result): LensResult => {
|
|
77
158
|
if (!Object.prototype.hasOwnProperty.call(signals, result.lens)) return result;
|
|
78
159
|
const own = signals[result.lens] ?? [];
|
|
160
|
+
const rejectLines =
|
|
161
|
+
context.rejects !== undefined &&
|
|
162
|
+
Object.prototype.hasOwnProperty.call(context.rejects, result.lens)
|
|
163
|
+
? (context.rejects[result.lens] ?? [])
|
|
164
|
+
: undefined;
|
|
79
165
|
let changed = false;
|
|
166
|
+
let lensPromoted = 0;
|
|
80
167
|
const findings = result.findings.map((finding): Finding => {
|
|
81
|
-
if (
|
|
82
|
-
|
|
168
|
+
if (isNone(finding)) return finding;
|
|
169
|
+
const text = `${finding.summary}\n${finding.detail ?? ''}`;
|
|
170
|
+
if (finding.blocking) {
|
|
171
|
+
if (quotesSignal(text, own, ids)) return finding;
|
|
172
|
+
changed = true;
|
|
173
|
+
downgraded++;
|
|
174
|
+
return { ...finding, blocking: false, summary: `${finding.summary}${DOWNGRADE_NOTE}` };
|
|
175
|
+
}
|
|
176
|
+
if (rejectLines === undefined || !quotesSignal(text, rejectLines, ids)) return finding;
|
|
83
177
|
changed = true;
|
|
84
|
-
|
|
85
|
-
|
|
178
|
+
promoted++;
|
|
179
|
+
lensPromoted++;
|
|
180
|
+
return { ...finding, blocking: true, summary: `${finding.summary}${PROMOTE_NOTE}` };
|
|
86
181
|
});
|
|
87
182
|
if (!changed) return result;
|
|
88
183
|
const outcome =
|
|
89
184
|
result.outcome === 'rejected' && !hasBlocking(findings)
|
|
90
185
|
? ('approved' as const)
|
|
91
|
-
: result.outcome
|
|
92
|
-
|
|
186
|
+
: result.outcome === 'approved' && lensPromoted > 0
|
|
187
|
+
? ('rejected' as const)
|
|
188
|
+
: result.outcome;
|
|
189
|
+
// The lens's own reason text is not regenerated, so a changed word must say who changed it.
|
|
190
|
+
return {
|
|
191
|
+
...result,
|
|
192
|
+
outcome,
|
|
193
|
+
findings,
|
|
194
|
+
...(outcome === result.outcome ? {} : { tierAdjusted: true }),
|
|
195
|
+
};
|
|
93
196
|
});
|
|
94
|
-
if (downgraded === 0) return { verdict, downgraded };
|
|
197
|
+
if (downgraded === 0 && promoted === 0) return { verdict, downgraded, promoted };
|
|
95
198
|
// Prior-round rulings are carried on each result; the CLI never supplies orphaned ones.
|
|
96
199
|
return {
|
|
97
200
|
verdict: aggregate(
|
|
@@ -99,5 +202,6 @@ export function enforceTier(
|
|
|
99
202
|
results,
|
|
100
203
|
),
|
|
101
204
|
downgraded,
|
|
205
|
+
promoted,
|
|
102
206
|
};
|
|
103
207
|
}
|
package/src/verdict.ts
CHANGED
|
@@ -90,6 +90,9 @@ export interface LensResult {
|
|
|
90
90
|
/** Why this lens could not run, or why it found nothing in scope.
|
|
91
91
|
* Meaningless on `approved` and `rejected`. */
|
|
92
92
|
readonly reason?: string;
|
|
93
|
+
/** True when tier enforcement (tier.ts) changed this lens's outcome after the model gave it,
|
|
94
|
+
* so the model's own `reason` text no longer matches. Absent otherwise. */
|
|
95
|
+
readonly tierAdjusted?: boolean;
|
|
93
96
|
/**
|
|
94
97
|
* This lens's own rulings on whichever prior blocking findings were
|
|
95
98
|
* attributed to it (`Subject.priorFindings`, filtered by lens). Present
|