@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.
- package/README.md +132 -2
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +46 -0
- package/dist/cli/footer.d.ts +9 -0
- package/dist/cli/footer.js +59 -0
- package/dist/cli/review.d.ts +45 -0
- package/dist/cli/review.js +507 -0
- package/dist/context-assembler.d.ts +9 -0
- package/dist/context-assembler.js +118 -0
- package/dist/context-files.d.ts +33 -0
- package/dist/context-files.js +174 -0
- package/dist/context-types.d.ts +28 -0
- package/dist/context-types.js +5 -0
- package/dist/dedup.d.ts +40 -0
- package/dist/dedup.js +151 -0
- package/dist/diff-hunks.d.ts +17 -0
- package/dist/diff-hunks.js +73 -0
- package/dist/fs-source-access.d.ts +7 -0
- package/dist/fs-source-access.js +39 -0
- package/dist/parseReply.js +58 -7
- package/dist/practice-lenses.d.ts +69 -0
- package/dist/practice-lenses.js +218 -0
- package/dist/prompt.d.ts +23 -0
- package/dist/prompt.js +18 -0
- package/dist/references.d.ts +44 -0
- package/dist/references.js +312 -0
- package/dist/report.d.ts +17 -1
- package/dist/report.js +56 -3
- package/dist/run-review.d.ts +16 -0
- package/dist/run-review.js +37 -2
- package/dist/source-access.d.ts +8 -0
- package/dist/source-access.js +1 -0
- package/dist/subject-context-render.d.ts +8 -0
- package/dist/subject-context-render.js +29 -0
- package/dist/tier.d.ts +20 -0
- package/dist/tier.js +88 -0
- package/dist/warm-first.d.ts +2 -0
- package/dist/warm-first.js +21 -0
- package/lenses.default.json +30 -0
- package/package.json +5 -1
- package/src/cli/bin.ts +61 -0
- package/src/cli/footer.ts +78 -0
- package/src/cli/review.ts +647 -0
- package/src/context-files.ts +194 -0
- package/src/dedup.ts +187 -0
- package/src/parseReply.ts +58 -7
- package/src/practice-lenses.ts +265 -0
- package/src/prompt.ts +44 -0
- package/src/references.ts +381 -0
- package/src/report.ts +71 -3
- package/src/run-review.ts +55 -7
- package/src/tier.ts +103 -0
- 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
|
|
191
|
-
const findings: Finding[] =
|
|
192
|
-
.filter((
|
|
193
|
-
.map((
|
|
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 =
|
|
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) {
|