@verevoir/review 0.2.0 → 0.3.0

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 (53) 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 +9 -0
  5. package/dist/cli/footer.js +59 -0
  6. package/dist/cli/review.d.ts +45 -0
  7. package/dist/cli/review.js +507 -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 +40 -0
  15. package/dist/dedup.js +151 -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 +69 -0
  22. package/dist/practice-lenses.js +218 -0
  23. package/dist/prompt.d.ts +23 -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 +56 -3
  29. package/dist/run-review.d.ts +16 -0
  30. package/dist/run-review.js +37 -2
  31. package/dist/source-access.d.ts +8 -0
  32. package/dist/source-access.js +1 -0
  33. package/dist/subject-context-render.d.ts +8 -0
  34. package/dist/subject-context-render.js +29 -0
  35. package/dist/tier.d.ts +20 -0
  36. package/dist/tier.js +88 -0
  37. package/dist/warm-first.d.ts +2 -0
  38. package/dist/warm-first.js +21 -0
  39. package/lenses.default.json +30 -0
  40. package/package.json +5 -1
  41. package/src/cli/bin.ts +61 -0
  42. package/src/cli/footer.ts +78 -0
  43. package/src/cli/review.ts +647 -0
  44. package/src/context-files.ts +194 -0
  45. package/src/dedup.ts +187 -0
  46. package/src/parseReply.ts +58 -7
  47. package/src/practice-lenses.ts +265 -0
  48. package/src/prompt.ts +44 -0
  49. package/src/references.ts +381 -0
  50. package/src/report.ts +71 -3
  51. package/src/run-review.ts +55 -7
  52. package/src/tier.ts +103 -0
  53. package/src/warm-first.ts +27 -0
@@ -0,0 +1,194 @@
1
+ // Whole-file context for a diff: for each file the diff touches that still
2
+ // exists at the change's head, its full post-change text, so a lens can see
3
+ // what sits outside the hunks (e.g. a workflow's `on:` block 30 lines above
4
+ // the changed step).
5
+ //
6
+ // SAFETY: paths come from the diff's own `+++` headers, so they are
7
+ // untrusted. A path is refused (never read) when it is absolute, contains a
8
+ // `..` segment or a NUL; and the resolved real path of every candidate must
9
+ // stay inside the real path of the context root, so a symlink pointing out
10
+ // of the tree is refused too. Refusals are listed in the section, not thrown.
11
+ //
12
+ // PURE-ish: all disk access goes through the injected `ContextFs`, so tests
13
+ // use fakes.
14
+
15
+ /** Files larger than this (bytes) are skipped, with the reason named. */
16
+ export const MAX_CONTEXT_FILE_BYTES = 16 * 1024;
17
+
18
+ /** The one line used when there is no context root (diff read from stdin). */
19
+ export const NO_FILE_CONTEXT_NOTE =
20
+ 'No file context: the diff was read from stdin and no --context-root was given, so only the ' +
21
+ 'diff hunks are shown.';
22
+
23
+ export interface ContextFs {
24
+ /** Resolve symlinks. Rejects with an fs-style `code` (ENOENT...) if absent. */
25
+ readonly realpath: (path: string) => Promise<string>;
26
+ readonly statFile: (path: string) => Promise<{ readonly size: number; readonly isFile: boolean }>;
27
+ readonly readFile: (path: string) => Promise<string>;
28
+ }
29
+
30
+ export interface DiffPath {
31
+ readonly path: string;
32
+ /** True when the file is deleted by the change (`+++ /dev/null`). */
33
+ readonly deleted: boolean;
34
+ }
35
+
36
+ function unquote(raw: string): string | undefined {
37
+ const tab = raw.indexOf('\t');
38
+ const trimmed = (tab === -1 ? raw : raw.slice(0, tab)).trim();
39
+ if (!trimmed.startsWith('"')) return trimmed;
40
+ try {
41
+ const parsed: unknown = JSON.parse(trimmed);
42
+ return typeof parsed === 'string' ? parsed : undefined;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ function stripPrefix(path: string, prefix: 'a/' | 'b/'): string {
49
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
50
+ }
51
+
52
+ /**
53
+ * The files a unified diff touches, from its `--- ` / `+++ ` header pairs
54
+ * (a `+++` only counts when the line before it is a `--- `). A path git had
55
+ * to quote with escapes is reported with `unreadable: true` via an empty
56
+ * path so the caller can say it was skipped.
57
+ */
58
+ export function diffPaths(diff: string): DiffPath[] {
59
+ const lines = diff.split(/\r?\n/);
60
+ const seen = new Set<string>();
61
+ const out: DiffPath[] = [];
62
+ for (let i = 0; i + 1 < lines.length; i++) {
63
+ const oldLine = lines[i] ?? '';
64
+ const newLine = lines[i + 1] ?? '';
65
+ if (!oldLine.startsWith('--- ') || !newLine.startsWith('+++ ')) continue;
66
+ const next = unquote(newLine.slice(4));
67
+ const prev = unquote(oldLine.slice(4));
68
+ let entry: DiffPath;
69
+ if (next === undefined || prev === undefined) {
70
+ entry = { path: '', deleted: false };
71
+ } else if (next === '/dev/null') {
72
+ entry = { path: stripPrefix(prev, 'a/'), deleted: true };
73
+ } else {
74
+ entry = { path: stripPrefix(next, 'b/'), deleted: false };
75
+ }
76
+ const key = `${entry.deleted ? 'D' : 'M'}:${entry.path}`;
77
+ if (seen.has(key)) continue;
78
+ seen.add(key);
79
+ out.push(entry);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /** Why a diff-supplied path must not be read, or undefined if it is acceptable. */
85
+ export function unsafePathReason(path: string): string | undefined {
86
+ if (path === '') return 'path could not be read from the diff header';
87
+ if (path.includes('\0')) return 'path contains a NUL byte';
88
+ if (path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(path)) {
89
+ return 'absolute path';
90
+ }
91
+ if (path.split(/[\\/]/).includes('..')) return 'path contains a ".." segment';
92
+ return undefined;
93
+ }
94
+
95
+ function isAbsent(cause: unknown): boolean {
96
+ const code = (cause as { code?: unknown }).code;
97
+ return code === 'ENOENT' || code === 'ENOTDIR';
98
+ }
99
+
100
+ function describe(cause: unknown): string {
101
+ return cause instanceof Error ? cause.message : String(cause);
102
+ }
103
+
104
+ function fenceFor(text: string): string {
105
+ let longest = 2;
106
+ for (const run of text.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
107
+ return '`'.repeat(longest + 1);
108
+ }
109
+
110
+ /**
111
+ * Build the body of the "Changed files in full" section for `diff`, reading
112
+ * from `root`. Throws only when the root itself cannot be resolved; every
113
+ * per-file problem becomes a "skipped" line naming the reason.
114
+ */
115
+ export async function buildFileContext(diff: string, root: string, fs: ContextFs): Promise<string> {
116
+ let rootReal: string;
117
+ try {
118
+ rootReal = await fs.realpath(root);
119
+ } catch (cause) {
120
+ throw new Error(`--context-root ${root} could not be resolved: ${describe(cause)}`);
121
+ }
122
+ const prefix = rootReal.endsWith('/') ? rootReal : `${rootReal}/`;
123
+
124
+ const shown: string[] = [];
125
+ const skipped: string[] = [];
126
+ for (const entry of diffPaths(diff)) {
127
+ const label = entry.path === '' ? '(unreadable path)' : entry.path;
128
+ if (entry.deleted) {
129
+ skipped.push(`- \`${label}\`: deleted by this change`);
130
+ continue;
131
+ }
132
+ const unsafe = unsafePathReason(entry.path);
133
+ if (unsafe !== undefined) {
134
+ skipped.push(`- \`${label}\`: refused (${unsafe})`);
135
+ continue;
136
+ }
137
+ let real: string;
138
+ try {
139
+ real = await fs.realpath(`${prefix}${entry.path}`);
140
+ } catch (cause) {
141
+ skipped.push(
142
+ isAbsent(cause)
143
+ ? `- \`${label}\`: not present at the context root`
144
+ : `- \`${label}\`: could not be resolved (${describe(cause)})`,
145
+ );
146
+ continue;
147
+ }
148
+ if (real !== rootReal && !real.startsWith(prefix)) {
149
+ skipped.push(`- \`${label}\`: refused (resolves outside the context root)`);
150
+ continue;
151
+ }
152
+ try {
153
+ const stat = await fs.statFile(real);
154
+ if (!stat.isFile) {
155
+ skipped.push(`- \`${label}\`: not a regular file`);
156
+ continue;
157
+ }
158
+ if (stat.size > MAX_CONTEXT_FILE_BYTES) {
159
+ skipped.push(
160
+ `- \`${label}\`: too large (${String(stat.size)} bytes; limit ` +
161
+ `${String(MAX_CONTEXT_FILE_BYTES)})`,
162
+ );
163
+ continue;
164
+ }
165
+ const text = await fs.readFile(real);
166
+ if (text.includes('\0')) {
167
+ skipped.push(`- \`${label}\`: binary file`);
168
+ continue;
169
+ }
170
+ const fence = fenceFor(text);
171
+ shown.push(`### ${entry.path}\n\n${fence}\n${text.replace(/\n$/, '')}\n${fence}`);
172
+ } catch (cause) {
173
+ skipped.push(
174
+ isAbsent(cause)
175
+ ? `- \`${label}\`: not present at the context root`
176
+ : `- \`${label}\`: could not be read (${describe(cause)})`,
177
+ );
178
+ }
179
+ }
180
+
181
+ const parts: string[] = [];
182
+ if (shown.length === 0 && skipped.length === 0) {
183
+ return 'No changed files could be identified from the diff headers, so only the hunks are shown.';
184
+ }
185
+ parts.push(
186
+ 'Each file below is its full text as it stands after this change, so context outside the ' +
187
+ 'diff hunks is visible. Lines the diff does not touch are unchanged context.',
188
+ );
189
+ if (shown.length > 0) parts.push(shown.join('\n\n'));
190
+ if (skipped.length > 0) {
191
+ parts.push(`Not shown in full:\n\n${skipped.join('\n')}`);
192
+ }
193
+ return parts.join('\n\n');
194
+ }
package/src/dedup.ts ADDED
@@ -0,0 +1,187 @@
1
+ // De-duplicates findings across lenses with ONE extra model call through the same Reviewer the
2
+ // panel used. The panel is not restructured: the Reviewer takes (lensId, prompt) and a transport
3
+ // uses the lens id only to key its resolved-model callback, so a non-lens call under the id
4
+ // `dedup` is an ordinary call that onCall records like any other (so its usage reaches the
5
+ // footer).
6
+ //
7
+ // The model returns groups of finding ids; the reply is validated deterministically (every
8
+ // input id in exactly one group, no unknown ids, no empty groups). Anything else -- a thrown
9
+ // call, unparseable text, a failed validation -- is a skip with a reason, never a guess.
10
+
11
+ import { normaliseReviewerReply } from './panel.js';
12
+ import type { Reviewer } from './panel.js';
13
+ import type { Prompt } from './prompt.js';
14
+ import { normaliseForQuote } from './tier.js';
15
+ import type { DedupGroupView } from './report.js';
16
+ import type { Finding } from './verdict.js';
17
+
18
+ /** The lens id the extra call is made under. */
19
+ export const DEDUP_LENS = 'dedup';
20
+
21
+ /** Detail text sent per finding is cut to this many characters. */
22
+ const DETAIL_LIMIT = 800;
23
+
24
+ export interface NumberedFinding {
25
+ /** `lens:index`, the index counting that lens's findings from 0 in report order. */
26
+ readonly id: string;
27
+ readonly finding: Finding;
28
+ }
29
+
30
+ export function numberFindings(findings: readonly Finding[]): NumberedFinding[] {
31
+ const next = new Map<string, number>();
32
+ return findings.map((finding) => {
33
+ const index = next.get(finding.lens) ?? 0;
34
+ next.set(finding.lens, index + 1);
35
+ return { id: `${finding.lens}:${String(index)}`, finding };
36
+ });
37
+ }
38
+
39
+ export function buildDedupPrompt(items: readonly NumberedFinding[]): Prompt {
40
+ const list = items.map(({ id, finding }) => {
41
+ const detail = (finding.detail ?? '').trim();
42
+ const shown = detail.length > DETAIL_LIMIT ? `${detail.slice(0, DETAIL_LIMIT)}...` : detail;
43
+ return [
44
+ `### ${id} [${finding.blocking ? 'blocking' : 'smell'}]`,
45
+ '',
46
+ finding.summary,
47
+ ...(shown === '' ? [] : ['', shown]),
48
+ ].join('\n');
49
+ });
50
+ const shared = [
51
+ 'Several reviewers each reviewed the same change and raised the findings below. Some of ' +
52
+ 'them describe the SAME defect, restated by different reviewers or in different words.',
53
+ '',
54
+ 'Group the findings that describe the same underlying defect: the same root cause in the ' +
55
+ 'same place. Findings that only touch the same file or the same practice but are ' +
56
+ 'different defects stay in separate groups. A finding with no duplicate is a group of ' +
57
+ 'one. Every id below must appear in exactly one group.',
58
+ '',
59
+ '## Findings',
60
+ '',
61
+ list.join('\n\n'),
62
+ ].join('\n');
63
+ const perLens = [
64
+ '## Your task',
65
+ '',
66
+ 'Reply with ONLY a JSON array of arrays of ids, for example ' +
67
+ '[["a:0","b:1"],["a:1"],["b:0"]]. No prose, no markdown fence, no verdict line.',
68
+ ].join('\n');
69
+ return { shared, perLens };
70
+ }
71
+
72
+ export type GroupsResult =
73
+ | { readonly ok: true; readonly groups: readonly (readonly string[])[] }
74
+ | { readonly ok: false; readonly reason: string };
75
+
76
+ /** Parse and validate a reply: every one of `ids` in exactly one group. */
77
+ export function parseGroups(reply: string, ids: readonly string[]): GroupsResult {
78
+ const start = reply.indexOf('[');
79
+ const end = reply.lastIndexOf(']');
80
+ if (start === -1 || end <= start) {
81
+ return { ok: false, reason: 'the reply held no JSON array of groups' };
82
+ }
83
+ let parsed: unknown;
84
+ try {
85
+ parsed = JSON.parse(reply.slice(start, end + 1));
86
+ } catch {
87
+ return { ok: false, reason: 'the reply was not valid JSON' };
88
+ }
89
+ if (!Array.isArray(parsed)) return { ok: false, reason: 'the reply was not an array' };
90
+ const known = new Set(ids);
91
+ const seen = new Set<string>();
92
+ const groups: string[][] = [];
93
+ for (const group of parsed as unknown[]) {
94
+ if (!Array.isArray(group) || group.length === 0) {
95
+ return { ok: false, reason: 'a group was empty or not an array' };
96
+ }
97
+ const members: string[] = [];
98
+ for (const id of group as unknown[]) {
99
+ if (typeof id !== 'string' || !known.has(id)) {
100
+ return { ok: false, reason: `a group named an unknown id (${JSON.stringify(id)})` };
101
+ }
102
+ if (seen.has(id)) return { ok: false, reason: `id ${id} appeared in more than one group` };
103
+ seen.add(id);
104
+ members.push(id);
105
+ }
106
+ groups.push(members);
107
+ }
108
+ const missing = ids.filter((id) => !seen.has(id));
109
+ if (missing.length > 0) {
110
+ return { ok: false, reason: `ids missing from every group: ${missing.join(', ')}` };
111
+ }
112
+ return { ok: true, groups };
113
+ }
114
+
115
+ export type DedupResult =
116
+ | { readonly ok: true; readonly groups: readonly (readonly NumberedFinding[])[] }
117
+ | { readonly ok: false; readonly reason: string };
118
+
119
+ /** Ask the model to group `items`; members come back in their input order. */
120
+ export async function dedupFindings(
121
+ items: readonly NumberedFinding[],
122
+ review: Reviewer,
123
+ ): Promise<DedupResult> {
124
+ let text: string;
125
+ try {
126
+ text = normaliseReviewerReply(await review(DEDUP_LENS, buildDedupPrompt(items))).text;
127
+ } catch (cause) {
128
+ return {
129
+ ok: false,
130
+ reason: `the extra call failed: ${cause instanceof Error ? cause.message : String(cause)}`,
131
+ };
132
+ }
133
+ const parsed = parseGroups(
134
+ text,
135
+ items.map((i) => i.id),
136
+ );
137
+ if (!parsed.ok) return { ok: false, reason: `the grouping was invalid: ${parsed.reason}` };
138
+ const position = new Map(items.map((item, index) => [item.id, index]));
139
+ const byId = new Map(items.map((item) => [item.id, item]));
140
+ const groups = parsed.groups
141
+ .map((ids) =>
142
+ ids
143
+ .slice()
144
+ .sort((a, b) => (position.get(a) ?? 0) - (position.get(b) ?? 0))
145
+ .map((id) => byId.get(id) as NumberedFinding),
146
+ )
147
+ .sort((a, b) => (position.get(a[0]?.id ?? '') ?? 0) - (position.get(b[0]?.id ?? '') ?? 0));
148
+ return { ok: true, groups };
149
+ }
150
+
151
+ /**
152
+ * 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
+ * and who raised it, naming the practices of each lens that the members' text mentions (by id
154
+ * or title).
155
+ */
156
+ export function toGroupViews(
157
+ groups: readonly (readonly NumberedFinding[])[],
158
+ practiceNames: Readonly<Record<string, readonly { id: string; title: string }[]>> = {},
159
+ ): DedupGroupView[] {
160
+ return groups.map((members) => {
161
+ const lenses: string[] = [];
162
+ for (const { finding } of members)
163
+ if (!lenses.includes(finding.lens)) lenses.push(finding.lens);
164
+ const raisedBy = lenses.map((lens) => {
165
+ const text = normaliseForQuote(
166
+ members
167
+ .filter((m) => m.finding.lens === lens)
168
+ .map((m) => `${m.finding.summary}\n${m.finding.detail ?? ''}`)
169
+ .join('\n'),
170
+ );
171
+ const named = (practiceNames[lens] ?? []).filter(
172
+ (p) =>
173
+ text.includes(normaliseForQuote(p.id)) ||
174
+ (p.title !== '' && text.includes(normaliseForQuote(p.title))),
175
+ );
176
+ return { lens, practices: named.map((p) => p.id) };
177
+ });
178
+ // The text of the highest-tier member, so a blocking group never shows a downgraded smell.
179
+ const shown = (members.find((m) => m.finding.blocking) ?? (members[0] as NumberedFinding))
180
+ .finding;
181
+ return {
182
+ blocking: members.some((m) => m.finding.blocking),
183
+ finding: shown,
184
+ raisedBy,
185
+ };
186
+ });
187
+ }
package/src/parseReply.ts CHANGED
@@ -98,6 +98,51 @@ function taggedLines(lines: readonly string[], marker: string): string[] {
98
98
  .map((line) => line.trimStart().slice(marker.length).trim());
99
99
  }
100
100
 
101
+ const ALL_MARKERS = [VERDICT_MARKER, FINDING_MARKER, REASON_MARKER, RULING_MARKER];
102
+
103
+ function isMarkerLine(line: string): boolean {
104
+ const trimmed = line.trimStart();
105
+ return ALL_MARKERS.some((marker) => trimmed.startsWith(marker));
106
+ }
107
+
108
+ /** An indented line, a list item, or a numbered item: a continuation even after a blank. */
109
+ function isIndentedOrListed(line: string): boolean {
110
+ return /^[ \t]+\S/.test(line) || /^[-*+]\s/.test(line) || /^\d+[.)]\s/.test(line);
111
+ }
112
+
113
+ /**
114
+ * Every `FINDING:` line with the lines that continue it. A finding runs from
115
+ * its marker line until the next marker line (FINDING:, VERDICT:, REASON:,
116
+ * RULING:), a markdown heading, or the end of the reply; a blank line ends it
117
+ * unless the next non-blank line is indented or a list item (so sub-bullets
118
+ * after a blank still belong to it). Without this a finding whose sentence
119
+ * wraps, or that is followed by sub-bullets, was cut at its first line.
120
+ */
121
+ function findingBlocks(lines: readonly string[]): { raw: string; detail: string }[] {
122
+ const blocks: { raw: string; detail: string }[] = [];
123
+ for (let i = 0; i < lines.length; i++) {
124
+ const line = lines[i] ?? '';
125
+ if (!line.trimStart().startsWith(FINDING_MARKER)) continue;
126
+ const raw = line.trimStart().slice(FINDING_MARKER.length).trim();
127
+ const detail: string[] = [];
128
+ let pendingBlanks = 0;
129
+ for (let j = i + 1; j < lines.length; j++) {
130
+ const next = lines[j] ?? '';
131
+ if (isMarkerLine(next) || /^#{1,6}\s/.test(next)) break;
132
+ if (next.trim() === '') {
133
+ pendingBlanks++;
134
+ continue;
135
+ }
136
+ if (pendingBlanks > 0 && !isIndentedOrListed(next)) break;
137
+ for (let b = 0; b < pendingBlanks; b++) detail.push('');
138
+ pendingBlanks = 0;
139
+ detail.push(next.replace(/\s+$/, ''));
140
+ }
141
+ blocks.push({ raw, detail: detail.join('\n') });
142
+ }
143
+ return blocks;
144
+ }
145
+
101
146
  function cannotRun(lens: string, reason: string): LensResult {
102
147
  return { lens, outcome: 'could_not_run', findings: [], reason };
103
148
  }
@@ -187,17 +232,23 @@ function parseReplyBase(lens: string, text: string): LensResult {
187
232
  return cannotRun(lens, `reply carried an empty ${VERDICT_MARKER} line`);
188
233
  }
189
234
 
190
- const findingLines = taggedLines(lines, FINDING_MARKER);
191
- const findings: Finding[] = findingLines
192
- .filter((raw) => raw !== '')
193
- .map((raw) => severityOf(lens, raw))
235
+ const blocks = findingBlocks(lines);
236
+ const findings: Finding[] = blocks
237
+ .filter((block) => block.raw !== '')
238
+ .map((block): Finding => {
239
+ const finding = severityOf(lens, block.raw);
240
+ return block.detail === '' ? finding : { ...finding, detail: block.detail };
241
+ })
194
242
  .filter((finding) => finding.summary !== '');
195
- const emptyFindings = findingLines.length - findings.length;
243
+ const emptyFindings = blocks.length - findings.length;
196
244
  const blockingFindings = findings.filter((finding) => finding.blocking);
197
245
 
198
246
  if (verdict === APPROVED_WORD || verdict === REJECTED_WORD) {
247
+ // An optional one-line account of the judgement, shown in the report next to the verdict.
248
+ const note = taggedLines(lines, REASON_MARKER).find((reason) => reason !== '');
249
+ const withNote = note === undefined ? {} : { reason: note };
199
250
  if (blockingFindings.length > 0) {
200
- return { lens, outcome: 'rejected', findings };
251
+ return { lens, outcome: 'rejected', findings, ...withNote };
201
252
  }
202
253
 
203
254
  if (emptyFindings > 0) {
@@ -218,7 +269,7 @@ function parseReplyBase(lens: string, text: string): LensResult {
218
269
  );
219
270
  }
220
271
 
221
- return { lens, outcome: 'approved', findings };
272
+ return { lens, outcome: 'approved', findings, ...withNote };
222
273
  }
223
274
 
224
275
  if (verdict === CANNOT_RUN_WORD) {