@verevoir/review 0.2.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.
Files changed (57) hide show
  1. package/README.md +132 -2
  2. package/dist/cli/bin.d.ts +2 -0
  3. package/dist/cli/bin.js +46 -0
  4. package/dist/cli/footer.d.ts +11 -0
  5. package/dist/cli/footer.js +68 -0
  6. package/dist/cli/review.d.ts +45 -0
  7. package/dist/cli/review.js +518 -0
  8. package/dist/context-assembler.d.ts +9 -0
  9. package/dist/context-assembler.js +118 -0
  10. package/dist/context-files.d.ts +33 -0
  11. package/dist/context-files.js +174 -0
  12. package/dist/context-types.d.ts +28 -0
  13. package/dist/context-types.js +5 -0
  14. package/dist/dedup.d.ts +46 -0
  15. package/dist/dedup.js +169 -0
  16. package/dist/diff-hunks.d.ts +17 -0
  17. package/dist/diff-hunks.js +73 -0
  18. package/dist/fs-source-access.d.ts +7 -0
  19. package/dist/fs-source-access.js +39 -0
  20. package/dist/parseReply.js +58 -7
  21. package/dist/practice-lenses.d.ts +78 -0
  22. package/dist/practice-lenses.js +230 -0
  23. package/dist/prompt.d.ts +26 -0
  24. package/dist/prompt.js +18 -0
  25. package/dist/references.d.ts +44 -0
  26. package/dist/references.js +312 -0
  27. package/dist/report.d.ts +17 -1
  28. package/dist/report.js +64 -7
  29. package/dist/reviewer-anthropic.js +7 -1
  30. package/dist/run-review.d.ts +24 -0
  31. package/dist/run-review.js +44 -2
  32. package/dist/source-access.d.ts +8 -0
  33. package/dist/source-access.js +1 -0
  34. package/dist/subject-context-render.d.ts +8 -0
  35. package/dist/subject-context-render.js +29 -0
  36. package/dist/tier.d.ts +35 -0
  37. package/dist/tier.js +172 -0
  38. package/dist/verdict.d.ts +3 -0
  39. package/dist/warm-first.d.ts +2 -0
  40. package/dist/warm-first.js +21 -0
  41. package/lenses.default.json +30 -0
  42. package/package.json +5 -1
  43. package/src/cli/bin.ts +61 -0
  44. package/src/cli/footer.ts +88 -0
  45. package/src/cli/review.ts +656 -0
  46. package/src/context-files.ts +194 -0
  47. package/src/dedup.ts +207 -0
  48. package/src/parseReply.ts +58 -7
  49. package/src/practice-lenses.ts +286 -0
  50. package/src/prompt.ts +47 -0
  51. package/src/references.ts +381 -0
  52. package/src/report.ts +81 -7
  53. package/src/reviewer-anthropic.ts +7 -1
  54. package/src/run-review.ts +70 -7
  55. package/src/tier.ts +207 -0
  56. package/src/verdict.ts +3 -0
  57. package/src/warm-first.ts +27 -0
package/src/tier.ts ADDED
@@ -0,0 +1,207 @@
1
+ // Enforces the tier in code. A derived lens is told to tag a finding [blocking] only when it
2
+ // quotes the signal it fails, but a model honours that loosely. After parsing, every [blocking]
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]. 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
7
+ // recomputed. Deterministic: no model call.
8
+ //
9
+ // MATCHING RULE. Both sides are normalised (lower-cased; markdown emphasis `*` `_` and
10
+ // backticks removed; quote marks removed; runs of whitespace collapsed to one space). A
11
+ // finding (summary + detail) quotes a signal line when it contains a contiguous
12
+ // MIN_QUOTE-character (24) run of the signal's normalised text, or the whole normalised line
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".
29
+
30
+ import { aggregate } from './verdict.js';
31
+ import type { Finding, LensResult, Verdict } from './verdict.js';
32
+
33
+ /** The shortest verbatim run of a signal line that counts as quoting it. */
34
+ export const MIN_QUOTE = 24;
35
+
36
+ /** Appended to the summary of a finding that was downgraded. */
37
+ export const DOWNGRADE_NOTE = ' (downgraded: no signal quoted)';
38
+
39
+ /** Appended to the summary of a smell that was promoted. */
40
+ export const PROMOTE_NOTE = ' (promoted: quotes a Reject signal)';
41
+
42
+ export function normaliseForQuote(text: string): string {
43
+ return text
44
+ .replace(/[*_`]/g, '')
45
+ .replace(/["'‘’“”«»]/g, '')
46
+ .replace(/\s+/g, ' ')
47
+ .trim()
48
+ .toLowerCase();
49
+ }
50
+
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));
101
+ for (const signal of signals) {
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;
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // Same test as verdict.ts's: a [blocking] line whose text begins "none" says "nothing found".
116
+ const NONE_SENTINEL = /^none(?:\s*$|\s*[-—]\s)/;
117
+
118
+ function isNone(finding: Finding): boolean {
119
+ return NONE_SENTINEL.test(finding.summary.trim());
120
+ }
121
+
122
+ function hasBlocking(findings: readonly Finding[]): boolean {
123
+ return findings.some((f) => f.blocking && !isNone(f));
124
+ }
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
+
133
+ export interface TierResult {
134
+ readonly verdict: Verdict;
135
+ /** How many findings were downgraded. */
136
+ readonly downgraded: number;
137
+ /** How many smells were promoted to [blocking]. */
138
+ readonly promoted: number;
139
+ }
140
+
141
+ /**
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.
148
+ */
149
+ export function enforceTier(
150
+ verdict: Verdict,
151
+ signals: Readonly<Record<string, readonly string[]>>,
152
+ context: TierContext = {},
153
+ ): TierResult {
154
+ const ids = context.practiceIds ?? [];
155
+ let downgraded = 0;
156
+ let promoted = 0;
157
+ const results = verdict.results.map((result): LensResult => {
158
+ if (!Object.prototype.hasOwnProperty.call(signals, result.lens)) return result;
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;
165
+ let changed = false;
166
+ let lensPromoted = 0;
167
+ const findings = result.findings.map((finding): 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;
177
+ changed = true;
178
+ promoted++;
179
+ lensPromoted++;
180
+ return { ...finding, blocking: true, summary: `${finding.summary}${PROMOTE_NOTE}` };
181
+ });
182
+ if (!changed) return result;
183
+ const outcome =
184
+ result.outcome === 'rejected' && !hasBlocking(findings)
185
+ ? ('approved' as const)
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
+ };
196
+ });
197
+ if (downgraded === 0 && promoted === 0) return { verdict, downgraded, promoted };
198
+ // Prior-round rulings are carried on each result; the CLI never supplies orphaned ones.
199
+ return {
200
+ verdict: aggregate(
201
+ verdict.results.map((r) => r.lens),
202
+ results,
203
+ ),
204
+ downgraded,
205
+ promoted,
206
+ };
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
@@ -0,0 +1,27 @@
1
+ // Cache warm-up for the fan. runPanel dispatches every lens at once, so with a
2
+ // provider prompt cache each lens writes the same shared prefix and none reads
3
+ // it. `warmFirst` wraps a Reviewer so the FIRST call made runs alone, and every
4
+ // later call waits for it to settle (success or failure) before starting: the
5
+ // rest then read the prefix the first one wrote.
6
+ //
7
+ // The seam is the Reviewer itself, so runPanel is untouched. The first caller
8
+ // is whichever lens runPanel invokes first (the first lens in the set); the
9
+ // check-and-set below runs synchronously within that first call, before any
10
+ // await, so no later call can also believe it is first.
11
+
12
+ import type { Reviewer } from './panel.js';
13
+
14
+ export function warmFirst(reviewer: Reviewer): Reviewer {
15
+ let first: Promise<void> | undefined;
16
+ return (lensId, prompt) => {
17
+ if (first === undefined) {
18
+ const call = reviewer(lensId, prompt);
19
+ first = call.then(
20
+ () => undefined,
21
+ () => undefined,
22
+ );
23
+ return call;
24
+ }
25
+ return first.then(() => reviewer(lensId, prompt));
26
+ };
27
+ }