@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/dist/tier.js ADDED
@@ -0,0 +1,172 @@
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
+ import { aggregate } from './verdict.js';
30
+ /** The shortest verbatim run of a signal line that counts as quoting it. */
31
+ export const MIN_QUOTE = 24;
32
+ /** Appended to the summary of a finding that was downgraded. */
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)';
36
+ export function normaliseForQuote(text) {
37
+ return text
38
+ .replace(/[*_`]/g, '')
39
+ .replace(/["'‘’“”«»]/g, '')
40
+ .replace(/\s+/g, ' ')
41
+ .trim()
42
+ .toLowerCase();
43
+ }
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));
86
+ for (const signal of signals) {
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
+ }
94
+ }
95
+ else if (quotesRun(haystack, boundaryForm(marked), true)) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+ // Same test as verdict.ts's: a [blocking] line whose text begins "none" says "nothing found".
102
+ const NONE_SENTINEL = /^none(?:\s*$|\s*[-—]\s)/;
103
+ function isNone(finding) {
104
+ return NONE_SENTINEL.test(finding.summary.trim());
105
+ }
106
+ function hasBlocking(findings) {
107
+ return findings.some((f) => f.blocking && !isNone(f));
108
+ }
109
+ /**
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.
116
+ */
117
+ export function enforceTier(verdict, signals, context = {}) {
118
+ const ids = context.practiceIds ?? [];
119
+ let downgraded = 0;
120
+ let promoted = 0;
121
+ const results = verdict.results.map((result) => {
122
+ if (!Object.prototype.hasOwnProperty.call(signals, result.lens))
123
+ return result;
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;
129
+ let changed = false;
130
+ let lensPromoted = 0;
131
+ const findings = result.findings.map((finding) => {
132
+ if (isNone(finding))
133
+ return finding;
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))
143
+ return finding;
144
+ changed = true;
145
+ promoted++;
146
+ lensPromoted++;
147
+ return { ...finding, blocking: true, summary: `${finding.summary}${PROMOTE_NOTE}` };
148
+ });
149
+ if (!changed)
150
+ return result;
151
+ const outcome = result.outcome === 'rejected' && !hasBlocking(findings)
152
+ ? 'approved'
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
+ };
163
+ });
164
+ if (downgraded === 0 && promoted === 0)
165
+ return { verdict, downgraded, promoted };
166
+ // Prior-round rulings are carried on each result; the CLI never supplies orphaned ones.
167
+ return {
168
+ verdict: aggregate(verdict.results.map((r) => r.lens), results),
169
+ downgraded,
170
+ promoted,
171
+ };
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
@@ -0,0 +1,2 @@
1
+ import type { Reviewer } from './panel.js';
2
+ export declare function warmFirst(reviewer: Reviewer): Reviewer;
@@ -0,0 +1,21 @@
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
+ export function warmFirst(reviewer) {
12
+ let first;
13
+ return (lensId, prompt) => {
14
+ if (first === undefined) {
15
+ const call = reviewer(lensId, prompt);
16
+ first = call.then(() => undefined, () => undefined);
17
+ return call;
18
+ }
19
+ return first.then(() => reviewer(lensId, prompt));
20
+ };
21
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "version": 1,
3
+ "lenses": [
4
+ {
5
+ "id": "correctness",
6
+ "title": "Correctness",
7
+ "focus": "Does the change do what the diff, its comments and its description say it does? Read the claim and the code as two independent assertions and look for the gap between them. Prefer a concrete failing input over a description of a risk. This dimension judges work performed against a claim about it, not a description of a requirement: where the change under review is itself a description of a requirement, there is no behaviour yet for this lens to check — answer out_of_scope rather than manufacturing a gap between a description and work that does not exist yet."
8
+ },
9
+ {
10
+ "id": "honesty",
11
+ "title": "Honesty of the record",
12
+ "focus": "Anything that reports a state it did not observe: a comment claiming a property the code does not have, a log line or return value describing work that did not happen, a success reported over an empty result, documentation asserting a behaviour the implementation lacks. This still applies in full where a description of a requirement makes a false claim about the system's CURRENT state — that is exactly as dishonest there as anywhere else. But a description of a requirement is not dishonest merely for reporting no success — the absence of a completion claim is not itself a finding, and must not be written up as one."
13
+ },
14
+ {
15
+ "id": "failure-modes",
16
+ "title": "Failure distinguishable from silence",
17
+ "focus": "Where the change can fail, can a reader tell which failure happened? Missing, empty and broken must stay three answers. A swallowed error, a catch that returns a default, or a single word covering two outcomes belongs here. This judges error handling that exists in the change. A description of a requirement has no catch, return or logged outcome yet — answer out_of_scope, not a review of prose about intended handling as though it were the handling itself."
18
+ },
19
+ {
20
+ "id": "verification",
21
+ "title": "Verification that verifies",
22
+ "focus": "Would each new or changed test fail if the code it covers were wrong? Look for assertions that hold vacuously, suites that pass because they found nothing to run, and guards whose only test exercises the pure function underneath rather than the guard. This judges a test that exists in the change. A description of a requirement — including one describing what a future test must prove — has no test yet for this lens to check against; answer out_of_scope, not a verdict on a stated intention."
23
+ },
24
+ {
25
+ "id": "blast-radius",
26
+ "title": "Scope, collateral and credentials",
27
+ "focus": "What does this change touch that it did not need to touch? Reformatting, drive-by renames, widened permissions, a credential reaching a wider process than before, or a bound that moved without being named."
28
+ }
29
+ ]
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verevoir/review",
3
- "version": "0.2.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",
@@ -12,8 +12,12 @@
12
12
  "import": "./dist/index.js"
13
13
  }
14
14
  },
15
+ "bin": {
16
+ "aigency-review": "./dist/cli/bin.js"
17
+ },
15
18
  "files": [
16
19
  "dist",
20
+ "lenses.default.json",
17
21
  "src",
18
22
  "tsconfig.json",
19
23
  "tsconfig.build.json",
package/src/cli/bin.ts ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // The process boundary for `aigency-review`: construct the real adapters and
4
+ // hand them to `main` (review.ts). No decision lives here.
5
+
6
+ import { execFile } from 'node:child_process';
7
+ import { appendFile, readFile, readdir, realpath, stat } from 'node:fs/promises';
8
+ import process from 'node:process';
9
+ import { promisify } from 'node:util';
10
+
11
+ import { main } from './review.js';
12
+
13
+ const execFileAsync = promisify(execFile);
14
+
15
+ async function readAll(stream: NodeJS.ReadableStream): Promise<string> {
16
+ const chunks: string[] = [];
17
+ stream.setEncoding('utf8');
18
+ for await (const chunk of stream) chunks.push(chunk as string);
19
+ return chunks.join('');
20
+ }
21
+
22
+ main(
23
+ process.argv.slice(2),
24
+ process.env,
25
+ {
26
+ readStdin: () => readAll(process.stdin),
27
+ stdout: (text) => void process.stdout.write(text),
28
+ stderr: (text) => void process.stderr.write(text),
29
+ readFile: (path) => readFile(path, 'utf8'),
30
+ appendFile: (path, text) => appendFile(path, text, 'utf8'),
31
+ readDir: async (path) =>
32
+ (await readdir(path, { withFileTypes: true })).filter((e) => e.isFile()).map((e) => e.name),
33
+ listDir: async (path) =>
34
+ (await readdir(path, { withFileTypes: true })).map((e) => ({
35
+ name: e.name,
36
+ isDir: e.isDirectory(),
37
+ })),
38
+ realpath: (path) => realpath(path),
39
+ statFile: async (path) => {
40
+ const s = await stat(path);
41
+ return { size: s.size, isFile: s.isFile() };
42
+ },
43
+ },
44
+ {
45
+ // execFile, no shell: a ref is an argument, never parsed as a command.
46
+ git: async (args) =>
47
+ (await execFileAsync('git', [...args], { maxBuffer: 512 * 1024 * 1024 })).stdout,
48
+ // dist/cli/bin.js and src/cli/bin.ts both sit two levels under the package root.
49
+ defaultLenses: () => readFile(new URL('../../lenses.default.json', import.meta.url), 'utf8'),
50
+ },
51
+ ).then(
52
+ (code) => {
53
+ process.exitCode = code;
54
+ },
55
+ (cause: unknown) => {
56
+ const message = cause instanceof Error ? cause.message : String(cause);
57
+ process.stderr.write('RESULT: could_not_run\n');
58
+ process.stderr.write(`Unexpected failure: ${message}\n`);
59
+ process.exitCode = 1;
60
+ },
61
+ );
@@ -0,0 +1,88 @@
1
+ // What judged a review: the model, the route (by NAME only -- never a key or a
2
+ // path), and per-lens token usage and wall time, from runReviewNode's `onCall`
3
+ // records. Pure formatting.
4
+
5
+ import type { RawCall } from '../raw-call.js';
6
+
7
+ export type RouteName = 'env' | 'key-file' | 'claude-cli';
8
+
9
+ function seconds(ms: number): string {
10
+ return `${(ms / 1000).toFixed(1)}s`;
11
+ }
12
+
13
+ function byLens(calls: readonly RawCall[]): RawCall[] {
14
+ return calls.slice().sort((a, b) => (a.lens < b.lens ? -1 : a.lens > b.lens ? 1 : 0));
15
+ }
16
+
17
+ function tokens(call: RawCall): string {
18
+ const u = call.usage;
19
+ if (u === undefined) return 'usage not reported';
20
+ const cache =
21
+ u.cacheReadInputTokens > 0 || u.cacheCreationInputTokens > 0
22
+ ? ` (cache read ${String(u.cacheReadInputTokens)}, cache write ` +
23
+ `${String(u.cacheCreationInputTokens)})`
24
+ : '';
25
+ return `${String(u.inputTokens)} input / ${String(u.outputTokens)} output tokens${cache}`;
26
+ }
27
+
28
+ /** The model(s) the transport reported, else the requested one, said as such. */
29
+ export function modelText(requested: string, calls: readonly RawCall[]): string {
30
+ const resolved = [
31
+ ...new Set(calls.map((c) => c.resolved).filter((m): m is string => m !== null)),
32
+ ].sort();
33
+ if (resolved.length === 0) {
34
+ return `model \`${requested}\` (as requested; the transport reported none)`;
35
+ }
36
+ const names = resolved.map((m) => `\`${m}\``).join(', ');
37
+ return resolved.length === 1 && resolved[0] === requested
38
+ ? `model ${names}`
39
+ : `model ${names} (requested \`${requested}\`)`;
40
+ }
41
+
42
+ /** The one line saying how many [blocking] findings tier enforcement downgraded; none for 0. */
43
+ export function tierLine(downgraded: number): string | undefined {
44
+ return downgraded > 0
45
+ ? `Tier: ${String(downgraded)} blocking finding(s) downgraded: no Reject/Expect signal quoted`
46
+ : undefined;
47
+ }
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
+
56
+ export function renderFooter(
57
+ requested: string,
58
+ route: RouteName,
59
+ calls: readonly RawCall[],
60
+ downgraded = 0,
61
+ promoted = 0,
62
+ ): string {
63
+ const lines = ['---', '', `Judged with ${modelText(requested, calls)}, route: ${route}.`];
64
+ const tier = tierLine(downgraded);
65
+ if (tier !== undefined) lines.push('', tier);
66
+ const promotion = promotedLine(promoted);
67
+ if (promotion !== undefined) lines.push('', promotion);
68
+ if (calls.length > 0) {
69
+ lines.push('', 'Per-lens usage and wall time:', '');
70
+ for (const call of byLens(calls)) {
71
+ lines.push(
72
+ call.failure === undefined
73
+ ? `- \`${call.lens}\`: ${tokens(call)}, ${seconds(call.durationMs)}`
74
+ : `- \`${call.lens}\`: failed after ${seconds(call.durationMs)}`,
75
+ );
76
+ }
77
+ }
78
+ return lines.join('\n');
79
+ }
80
+
81
+ /** One stderr line per lens. */
82
+ export function usageLines(calls: readonly RawCall[]): string[] {
83
+ return byLens(calls).map((call) =>
84
+ call.failure === undefined
85
+ ? `lens ${call.lens}: ${tokens(call)}, ${seconds(call.durationMs)}\n`
86
+ : `lens ${call.lens}: failed after ${seconds(call.durationMs)}\n`,
87
+ );
88
+ }