@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.
@@ -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
- export declare function renderFooter(requested: string, route: RouteName, calls: readonly RawCall[], downgraded?: number): string;
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[];
@@ -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
- export function renderFooter(requested, route, calls, downgraded = 0) {
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)) {
@@ -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
- : { tierSignals: derived.signals, practiceNames: derived.practiceNames }),
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 footer = `\n\n${renderFooter(model, route, calls, result.downgraded)}`;
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
- return { shared, perLens };
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
@@ -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}(?:Reject|Expect):[_*]{1,2}\s*(.*)$/i;
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
- let text = (match[1] ?? '').trim();
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
- (signals[lensId] ??= []).push(...(sensor === undefined ? [] : extractSignalLines(sensor)));
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
- return reason === '' ? `${lens}: ${result.outcome}` : `${lens}: ${result.outcome}: ${reason}`;
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
- ? smellCount > 0
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
- : smellCount > 0
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 = smellCount > 0
163
- ? ` ${smellCount} non-blocking finding(s) are listed above; none of them held this up. ` +
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
- system: [{ type: 'text', text: prompt.shared, cache_control: { type: 'ephemeral' } }],
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;
@@ -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>;
@@ -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
- /** Whether `text` contains a verbatim quote (per the rule above) of any of `signals`. */
8
- export declare function quotesSignal(text: string, signals: readonly string[]): boolean;
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`, then
16
- * re-aggregate. A lens that was `rejected` and is left with no [blocking] finding counts as
17
- * `approved`. Lenses without an entry (bundled lenses) are untouched. With nothing to
18
- * downgrade the verdict is returned as it was.
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[]>>): TierResult;
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] and the lens's outcome is
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
- /** Whether `text` contains a verbatim quote (per the rule above) of any of `signals`. */
26
- export function quotesSignal(text, signals) {
27
- const haystack = normaliseForQuote(text);
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 needle = normaliseForQuote(signal);
30
- if (needle === '')
31
- continue;
32
- if (needle.length <= MIN_QUOTE) {
33
- if (haystack.includes(needle))
34
- return true;
35
- continue;
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
- for (let i = 0; i + MIN_QUOTE <= needle.length; i++) {
38
- if (haystack.includes(needle.slice(i, i + MIN_QUOTE)))
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`, then
54
- * re-aggregate. A lens that was `rejected` and is left with no [blocking] finding counts as
55
- * `approved`. Lenses without an entry (bundled lenses) are untouched. With nothing to
56
- * downgrade the verdict is returned as it was.
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 (!finding.blocking || isNone(finding))
132
+ if (isNone(finding))
67
133
  return finding;
68
- if (quotesSignal(`${finding.summary}\n${finding.detail ?? ''}`, own))
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
- downgraded++;
72
- return { ...finding, blocking: false, summary: `${finding.summary}${DOWNGRADE_NOTE}` };
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
- return { ...result, outcome, findings };
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.0",
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
- : { tierSignals: derived.signals, practiceNames: derived.practiceNames }),
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 footer = `\n\n${renderFooter(model, route, calls, result.downgraded)}`;
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
- return { shared, perLens };
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
@@ -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}(?:Reject|Expect):[_*]{1,2}\s*(.*)$/i;
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
- const out: string[] = [];
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
- let text = (match[1] ?? '').trim();
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
- (signals[lensId] ??= []).push(...(sensor === undefined ? [] : extractSignalLines(sensor)));
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
- return reason === '' ? `${lens}: ${result.outcome}` : `${lens}: ${result.outcome}: ${reason}`;
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
- ? smellCount > 0
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
- : smellCount > 0
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
- smellCount > 0
219
- ? ` ${smellCount} non-blocking finding(s) are listed above; none of them held this up. ` +
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
- system: [{ type: 'text', text: prompt.shared, cache_control: { type: 'ephemeral' } }],
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] and the lens's outcome is
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
- /** Whether `text` contains a verbatim quote (per the rule above) of any of `signals`. */
32
- export function quotesSignal(text: string, signals: readonly string[]): boolean {
33
- const haystack = normaliseForQuote(text);
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 needle = normaliseForQuote(signal);
36
- if (needle === '') continue;
37
- if (needle.length <= MIN_QUOTE) {
38
- if (haystack.includes(needle)) return true;
39
- continue;
40
- }
41
- for (let i = 0; i + MIN_QUOTE <= needle.length; i++) {
42
- if (haystack.includes(needle.slice(i, i + MIN_QUOTE))) return true;
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`, then
67
- * re-aggregate. A lens that was `rejected` and is left with no [blocking] finding counts as
68
- * `approved`. Lenses without an entry (bundled lenses) are untouched. With nothing to
69
- * downgrade the verdict is returned as it was.
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 (!finding.blocking || isNone(finding)) return finding;
82
- if (quotesSignal(`${finding.summary}\n${finding.detail ?? ''}`, own)) return finding;
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
- downgraded++;
85
- return { ...finding, blocking: false, summary: `${finding.summary}${DOWNGRADE_NOTE}` };
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
- return { ...result, outcome, findings };
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