@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,174 @@
|
|
|
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
|
+
/** Files larger than this (bytes) are skipped, with the reason named. */
|
|
15
|
+
export const MAX_CONTEXT_FILE_BYTES = 16 * 1024;
|
|
16
|
+
/** The one line used when there is no context root (diff read from stdin). */
|
|
17
|
+
export const NO_FILE_CONTEXT_NOTE = 'No file context: the diff was read from stdin and no --context-root was given, so only the ' +
|
|
18
|
+
'diff hunks are shown.';
|
|
19
|
+
function unquote(raw) {
|
|
20
|
+
const tab = raw.indexOf('\t');
|
|
21
|
+
const trimmed = (tab === -1 ? raw : raw.slice(0, tab)).trim();
|
|
22
|
+
if (!trimmed.startsWith('"'))
|
|
23
|
+
return trimmed;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(trimmed);
|
|
26
|
+
return typeof parsed === 'string' ? parsed : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function stripPrefix(path, prefix) {
|
|
33
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The files a unified diff touches, from its `--- ` / `+++ ` header pairs
|
|
37
|
+
* (a `+++` only counts when the line before it is a `--- `). A path git had
|
|
38
|
+
* to quote with escapes is reported with `unreadable: true` via an empty
|
|
39
|
+
* path so the caller can say it was skipped.
|
|
40
|
+
*/
|
|
41
|
+
export function diffPaths(diff) {
|
|
42
|
+
const lines = diff.split(/\r?\n/);
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const out = [];
|
|
45
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
46
|
+
const oldLine = lines[i] ?? '';
|
|
47
|
+
const newLine = lines[i + 1] ?? '';
|
|
48
|
+
if (!oldLine.startsWith('--- ') || !newLine.startsWith('+++ '))
|
|
49
|
+
continue;
|
|
50
|
+
const next = unquote(newLine.slice(4));
|
|
51
|
+
const prev = unquote(oldLine.slice(4));
|
|
52
|
+
let entry;
|
|
53
|
+
if (next === undefined || prev === undefined) {
|
|
54
|
+
entry = { path: '', deleted: false };
|
|
55
|
+
}
|
|
56
|
+
else if (next === '/dev/null') {
|
|
57
|
+
entry = { path: stripPrefix(prev, 'a/'), deleted: true };
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
entry = { path: stripPrefix(next, 'b/'), deleted: false };
|
|
61
|
+
}
|
|
62
|
+
const key = `${entry.deleted ? 'D' : 'M'}:${entry.path}`;
|
|
63
|
+
if (seen.has(key))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
out.push(entry);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Why a diff-supplied path must not be read, or undefined if it is acceptable. */
|
|
71
|
+
export function unsafePathReason(path) {
|
|
72
|
+
if (path === '')
|
|
73
|
+
return 'path could not be read from the diff header';
|
|
74
|
+
if (path.includes('\0'))
|
|
75
|
+
return 'path contains a NUL byte';
|
|
76
|
+
if (path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(path)) {
|
|
77
|
+
return 'absolute path';
|
|
78
|
+
}
|
|
79
|
+
if (path.split(/[\\/]/).includes('..'))
|
|
80
|
+
return 'path contains a ".." segment';
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
function isAbsent(cause) {
|
|
84
|
+
const code = cause.code;
|
|
85
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
86
|
+
}
|
|
87
|
+
function describe(cause) {
|
|
88
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
89
|
+
}
|
|
90
|
+
function fenceFor(text) {
|
|
91
|
+
let longest = 2;
|
|
92
|
+
for (const run of text.match(/`+/g) ?? [])
|
|
93
|
+
longest = Math.max(longest, run.length);
|
|
94
|
+
return '`'.repeat(longest + 1);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build the body of the "Changed files in full" section for `diff`, reading
|
|
98
|
+
* from `root`. Throws only when the root itself cannot be resolved; every
|
|
99
|
+
* per-file problem becomes a "skipped" line naming the reason.
|
|
100
|
+
*/
|
|
101
|
+
export async function buildFileContext(diff, root, fs) {
|
|
102
|
+
let rootReal;
|
|
103
|
+
try {
|
|
104
|
+
rootReal = await fs.realpath(root);
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
throw new Error(`--context-root ${root} could not be resolved: ${describe(cause)}`);
|
|
108
|
+
}
|
|
109
|
+
const prefix = rootReal.endsWith('/') ? rootReal : `${rootReal}/`;
|
|
110
|
+
const shown = [];
|
|
111
|
+
const skipped = [];
|
|
112
|
+
for (const entry of diffPaths(diff)) {
|
|
113
|
+
const label = entry.path === '' ? '(unreadable path)' : entry.path;
|
|
114
|
+
if (entry.deleted) {
|
|
115
|
+
skipped.push(`- \`${label}\`: deleted by this change`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const unsafe = unsafePathReason(entry.path);
|
|
119
|
+
if (unsafe !== undefined) {
|
|
120
|
+
skipped.push(`- \`${label}\`: refused (${unsafe})`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
let real;
|
|
124
|
+
try {
|
|
125
|
+
real = await fs.realpath(`${prefix}${entry.path}`);
|
|
126
|
+
}
|
|
127
|
+
catch (cause) {
|
|
128
|
+
skipped.push(isAbsent(cause)
|
|
129
|
+
? `- \`${label}\`: not present at the context root`
|
|
130
|
+
: `- \`${label}\`: could not be resolved (${describe(cause)})`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (real !== rootReal && !real.startsWith(prefix)) {
|
|
134
|
+
skipped.push(`- \`${label}\`: refused (resolves outside the context root)`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const stat = await fs.statFile(real);
|
|
139
|
+
if (!stat.isFile) {
|
|
140
|
+
skipped.push(`- \`${label}\`: not a regular file`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (stat.size > MAX_CONTEXT_FILE_BYTES) {
|
|
144
|
+
skipped.push(`- \`${label}\`: too large (${String(stat.size)} bytes; limit ` +
|
|
145
|
+
`${String(MAX_CONTEXT_FILE_BYTES)})`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const text = await fs.readFile(real);
|
|
149
|
+
if (text.includes('\0')) {
|
|
150
|
+
skipped.push(`- \`${label}\`: binary file`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const fence = fenceFor(text);
|
|
154
|
+
shown.push(`### ${entry.path}\n\n${fence}\n${text.replace(/\n$/, '')}\n${fence}`);
|
|
155
|
+
}
|
|
156
|
+
catch (cause) {
|
|
157
|
+
skipped.push(isAbsent(cause)
|
|
158
|
+
? `- \`${label}\`: not present at the context root`
|
|
159
|
+
: `- \`${label}\`: could not be read (${describe(cause)})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const parts = [];
|
|
163
|
+
if (shown.length === 0 && skipped.length === 0) {
|
|
164
|
+
return 'No changed files could be identified from the diff headers, so only the hunks are shown.';
|
|
165
|
+
}
|
|
166
|
+
parts.push('Each file below is its full text as it stands after this change, so context outside the ' +
|
|
167
|
+
'diff hunks is visible. Lines the diff does not touch are unchanged context.');
|
|
168
|
+
if (shown.length > 0)
|
|
169
|
+
parts.push(shown.join('\n\n'));
|
|
170
|
+
if (skipped.length > 0) {
|
|
171
|
+
parts.push(`Not shown in full:\n\n${skipped.join('\n')}`);
|
|
172
|
+
}
|
|
173
|
+
return parts.join('\n\n');
|
|
174
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** One of the diff's own touched symbols, with evidence about it drawn
|
|
2
|
+
* from the rest of the tree. `definition` is absent only when the
|
|
3
|
+
* assembler chose not to include a body (reserved for a future budget
|
|
4
|
+
* mode that keeps the reference but drops the text); PR 2 always
|
|
5
|
+
* populates it when a symbol is included at all. */
|
|
6
|
+
export interface SubjectContextEntry {
|
|
7
|
+
readonly symbol: string;
|
|
8
|
+
readonly file: string;
|
|
9
|
+
readonly definition?: string;
|
|
10
|
+
/** Names of symbols, anywhere in the warmed tree, with a call edge TO
|
|
11
|
+
* this symbol. Name-based, approximate -- @verevoir/context's own
|
|
12
|
+
* documented limit (no type resolution). */
|
|
13
|
+
readonly callers: readonly string[];
|
|
14
|
+
/** Names of symbols this symbol's own body calls. Same name-based
|
|
15
|
+
* approximation as `callers`. */
|
|
16
|
+
readonly callees: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
/** A symbol or file the assembler could not resolve, and why -- never a
|
|
19
|
+
* silent empty result. Mirrors the "disclosed, never silently absent"
|
|
20
|
+
* discipline v5's own symbol-table.ts documented for its empty cases. */
|
|
21
|
+
export interface SubjectContextOmission {
|
|
22
|
+
readonly symbol: string;
|
|
23
|
+
readonly reason: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SubjectContext {
|
|
26
|
+
readonly entries: readonly SubjectContextEntry[];
|
|
27
|
+
readonly omitted: readonly SubjectContextOmission[];
|
|
28
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The shape of CONTEXT about a diff -- evidence drawn read-only from the
|
|
2
|
+
// codebase at the head ref, never a second subject. Kept in its own file
|
|
3
|
+
// (no logic here) so both the assembler (context-assembler.ts) and the
|
|
4
|
+
// renderer (subject-context-render.ts) depend on one definition.
|
|
5
|
+
export {};
|
package/dist/dedup.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Reviewer } from './panel.js';
|
|
2
|
+
import type { Prompt } from './prompt.js';
|
|
3
|
+
import type { DedupGroupView } from './report.js';
|
|
4
|
+
import type { Finding } from './verdict.js';
|
|
5
|
+
/** The lens id the extra call is made under. */
|
|
6
|
+
export declare const DEDUP_LENS = "dedup";
|
|
7
|
+
export interface NumberedFinding {
|
|
8
|
+
/** `lens:index`, the index counting that lens's findings from 0 in report order. */
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly finding: Finding;
|
|
11
|
+
}
|
|
12
|
+
export declare function numberFindings(findings: readonly Finding[]): NumberedFinding[];
|
|
13
|
+
export declare function buildDedupPrompt(items: readonly NumberedFinding[]): Prompt;
|
|
14
|
+
export type GroupsResult = {
|
|
15
|
+
readonly ok: true;
|
|
16
|
+
readonly groups: readonly (readonly string[])[];
|
|
17
|
+
} | {
|
|
18
|
+
readonly ok: false;
|
|
19
|
+
readonly reason: string;
|
|
20
|
+
};
|
|
21
|
+
/** Parse and validate a reply: every one of `ids` in exactly one group. */
|
|
22
|
+
export declare function parseGroups(reply: string, ids: readonly string[]): GroupsResult;
|
|
23
|
+
export type DedupResult = {
|
|
24
|
+
readonly ok: true;
|
|
25
|
+
readonly groups: readonly (readonly NumberedFinding[])[];
|
|
26
|
+
} | {
|
|
27
|
+
readonly ok: false;
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
};
|
|
30
|
+
/** Ask the model to group `items`; members come back in their input order. */
|
|
31
|
+
export declare function dedupFindings(items: readonly NumberedFinding[], review: Reviewer): Promise<DedupResult>;
|
|
32
|
+
/**
|
|
33
|
+
* 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
|
+
* and who raised it, naming the practices of each lens that the members' text mentions (by id
|
|
35
|
+
* or title).
|
|
36
|
+
*/
|
|
37
|
+
export declare function toGroupViews(groups: readonly (readonly NumberedFinding[])[], practiceNames?: Readonly<Record<string, readonly {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
}[]>>): DedupGroupView[];
|
package/dist/dedup.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
import { normaliseReviewerReply } from './panel.js';
|
|
11
|
+
import { normaliseForQuote } from './tier.js';
|
|
12
|
+
/** The lens id the extra call is made under. */
|
|
13
|
+
export const DEDUP_LENS = 'dedup';
|
|
14
|
+
/** Detail text sent per finding is cut to this many characters. */
|
|
15
|
+
const DETAIL_LIMIT = 800;
|
|
16
|
+
export function numberFindings(findings) {
|
|
17
|
+
const next = new Map();
|
|
18
|
+
return findings.map((finding) => {
|
|
19
|
+
const index = next.get(finding.lens) ?? 0;
|
|
20
|
+
next.set(finding.lens, index + 1);
|
|
21
|
+
return { id: `${finding.lens}:${String(index)}`, finding };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function buildDedupPrompt(items) {
|
|
25
|
+
const list = items.map(({ id, finding }) => {
|
|
26
|
+
const detail = (finding.detail ?? '').trim();
|
|
27
|
+
const shown = detail.length > DETAIL_LIMIT ? `${detail.slice(0, DETAIL_LIMIT)}...` : detail;
|
|
28
|
+
return [
|
|
29
|
+
`### ${id} [${finding.blocking ? 'blocking' : 'smell'}]`,
|
|
30
|
+
'',
|
|
31
|
+
finding.summary,
|
|
32
|
+
...(shown === '' ? [] : ['', shown]),
|
|
33
|
+
].join('\n');
|
|
34
|
+
});
|
|
35
|
+
const shared = [
|
|
36
|
+
'Several reviewers each reviewed the same change and raised the findings below. Some of ' +
|
|
37
|
+
'them describe the SAME defect, restated by different reviewers or in different words.',
|
|
38
|
+
'',
|
|
39
|
+
'Group the findings that describe the same underlying defect: the same root cause in the ' +
|
|
40
|
+
'same place. Findings that only touch the same file or the same practice but are ' +
|
|
41
|
+
'different defects stay in separate groups. A finding with no duplicate is a group of ' +
|
|
42
|
+
'one. Every id below must appear in exactly one group.',
|
|
43
|
+
'',
|
|
44
|
+
'## Findings',
|
|
45
|
+
'',
|
|
46
|
+
list.join('\n\n'),
|
|
47
|
+
].join('\n');
|
|
48
|
+
const perLens = [
|
|
49
|
+
'## Your task',
|
|
50
|
+
'',
|
|
51
|
+
'Reply with ONLY a JSON array of arrays of ids, for example ' +
|
|
52
|
+
'[["a:0","b:1"],["a:1"],["b:0"]]. No prose, no markdown fence, no verdict line.',
|
|
53
|
+
].join('\n');
|
|
54
|
+
return { shared, perLens };
|
|
55
|
+
}
|
|
56
|
+
/** Parse and validate a reply: every one of `ids` in exactly one group. */
|
|
57
|
+
export function parseGroups(reply, ids) {
|
|
58
|
+
const start = reply.indexOf('[');
|
|
59
|
+
const end = reply.lastIndexOf(']');
|
|
60
|
+
if (start === -1 || end <= start) {
|
|
61
|
+
return { ok: false, reason: 'the reply held no JSON array of groups' };
|
|
62
|
+
}
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(reply.slice(start, end + 1));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { ok: false, reason: 'the reply was not valid JSON' };
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(parsed))
|
|
71
|
+
return { ok: false, reason: 'the reply was not an array' };
|
|
72
|
+
const known = new Set(ids);
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const groups = [];
|
|
75
|
+
for (const group of parsed) {
|
|
76
|
+
if (!Array.isArray(group) || group.length === 0) {
|
|
77
|
+
return { ok: false, reason: 'a group was empty or not an array' };
|
|
78
|
+
}
|
|
79
|
+
const members = [];
|
|
80
|
+
for (const id of group) {
|
|
81
|
+
if (typeof id !== 'string' || !known.has(id)) {
|
|
82
|
+
return { ok: false, reason: `a group named an unknown id (${JSON.stringify(id)})` };
|
|
83
|
+
}
|
|
84
|
+
if (seen.has(id))
|
|
85
|
+
return { ok: false, reason: `id ${id} appeared in more than one group` };
|
|
86
|
+
seen.add(id);
|
|
87
|
+
members.push(id);
|
|
88
|
+
}
|
|
89
|
+
groups.push(members);
|
|
90
|
+
}
|
|
91
|
+
const missing = ids.filter((id) => !seen.has(id));
|
|
92
|
+
if (missing.length > 0) {
|
|
93
|
+
return { ok: false, reason: `ids missing from every group: ${missing.join(', ')}` };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, groups };
|
|
96
|
+
}
|
|
97
|
+
/** Ask the model to group `items`; members come back in their input order. */
|
|
98
|
+
export async function dedupFindings(items, review) {
|
|
99
|
+
let text;
|
|
100
|
+
try {
|
|
101
|
+
text = normaliseReviewerReply(await review(DEDUP_LENS, buildDedupPrompt(items))).text;
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
reason: `the extra call failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseGroups(text, items.map((i) => i.id));
|
|
110
|
+
if (!parsed.ok)
|
|
111
|
+
return { ok: false, reason: `the grouping was invalid: ${parsed.reason}` };
|
|
112
|
+
const position = new Map(items.map((item, index) => [item.id, index]));
|
|
113
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
114
|
+
const groups = parsed.groups
|
|
115
|
+
.map((ids) => ids
|
|
116
|
+
.slice()
|
|
117
|
+
.sort((a, b) => (position.get(a) ?? 0) - (position.get(b) ?? 0))
|
|
118
|
+
.map((id) => byId.get(id)))
|
|
119
|
+
.sort((a, b) => (position.get(a[0]?.id ?? '') ?? 0) - (position.get(b[0]?.id ?? '') ?? 0));
|
|
120
|
+
return { ok: true, groups };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 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
|
+
* and who raised it, naming the practices of each lens that the members' text mentions (by id
|
|
125
|
+
* or title).
|
|
126
|
+
*/
|
|
127
|
+
export function toGroupViews(groups, practiceNames = {}) {
|
|
128
|
+
return groups.map((members) => {
|
|
129
|
+
const lenses = [];
|
|
130
|
+
for (const { finding } of members)
|
|
131
|
+
if (!lenses.includes(finding.lens))
|
|
132
|
+
lenses.push(finding.lens);
|
|
133
|
+
const raisedBy = lenses.map((lens) => {
|
|
134
|
+
const text = normaliseForQuote(members
|
|
135
|
+
.filter((m) => m.finding.lens === lens)
|
|
136
|
+
.map((m) => `${m.finding.summary}\n${m.finding.detail ?? ''}`)
|
|
137
|
+
.join('\n'));
|
|
138
|
+
const named = (practiceNames[lens] ?? []).filter((p) => text.includes(normaliseForQuote(p.id)) ||
|
|
139
|
+
(p.title !== '' && text.includes(normaliseForQuote(p.title))));
|
|
140
|
+
return { lens, practices: named.map((p) => p.id) };
|
|
141
|
+
});
|
|
142
|
+
// The text of the highest-tier member, so a blocking group never shows a downgraded smell.
|
|
143
|
+
const shown = (members.find((m) => m.finding.blocking) ?? members[0])
|
|
144
|
+
.finding;
|
|
145
|
+
return {
|
|
146
|
+
blocking: members.some((m) => m.finding.blocking),
|
|
147
|
+
finding: shown,
|
|
148
|
+
raisedBy,
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface HunkRange {
|
|
2
|
+
readonly startLine: number;
|
|
3
|
+
readonly endLine: number;
|
|
4
|
+
}
|
|
5
|
+
/** path -> new-side line ranges the diff's hunks actually cover, in the
|
|
6
|
+
* order they appear. A path present in the diff with every hunk a pure
|
|
7
|
+
* deletion maps to an empty array, deliberately -- "touched, but nothing
|
|
8
|
+
* to show on the new side" is a real, distinct answer from "not in this
|
|
9
|
+
* diff at all" (absent from the map entirely). */
|
|
10
|
+
export declare function parseDiffHunks(diffText: string): ReadonlyMap<string, readonly HunkRange[]>;
|
|
11
|
+
/** Every path the diff's `+++ b/<path>` headers name, in order of first
|
|
12
|
+
* appearance. */
|
|
13
|
+
export declare function touchedFiles(diffText: string): readonly string[];
|
|
14
|
+
/** True iff `[startLine, endLine]` overlaps at least one of the diff's
|
|
15
|
+
* new-side hunk ranges for `path`. A path not in the diff, or present
|
|
16
|
+
* with only pure-deletion hunks, never overlaps anything. */
|
|
17
|
+
export declare function rangeTouchedByDiff(hunks: ReadonlyMap<string, readonly HunkRange[]>, path: string, startLine: number, endLine: number): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Parses unified-diff hunk headers to find which NEW-side line ranges a
|
|
2
|
+
// diff actually touches, per file. This is intentionally narrow: it reads
|
|
3
|
+
// `+++ b/<path>` file headers and `@@ -a,b +c,d @@` hunk headers, nothing
|
|
4
|
+
// else -- no `---` old-side headers, no rename detection, no binary-diff
|
|
5
|
+
// markers. It is the shared primitive this library's context assembler
|
|
6
|
+
// (PR 2) uses to decide which symbols in a touched file count as
|
|
7
|
+
// "touched", and PR 3's diff-anchor validator is expected to use the same
|
|
8
|
+
// primitive to decide whether a finding's cited location is inside the
|
|
9
|
+
// diff at all -- written once here so both consumers agree on hunk
|
|
10
|
+
// semantics, rather than parsing unified-diff text twice independently
|
|
11
|
+
// the way v5 duplicated `git diff --merge-base` across preflight.mjs and
|
|
12
|
+
// the CI workflow (named explicitly in this library's own grounding).
|
|
13
|
+
//
|
|
14
|
+
// NEW-side only: a pure deletion (a hunk whose new-side count is 0)
|
|
15
|
+
// contributes no range. That is a deliberate, narrow simplification for
|
|
16
|
+
// this file's two current consumers -- "which symbols does this diff
|
|
17
|
+
// touch" is answered well enough by the lines that still exist after the
|
|
18
|
+
// change. PR 3's anchor validator may need the OLD-side range too, for a
|
|
19
|
+
// finding that cites a deleted line; that is this file's own documented
|
|
20
|
+
// gap, not silently assumed away.
|
|
21
|
+
//
|
|
22
|
+
// A file diffed only as a deletion recorded as `+++ /dev/null` never
|
|
23
|
+
// appears here at all: the file-header pattern only recognises `+++
|
|
24
|
+
// b/<path>`, and `/dev/null` is never a real path worth resolving symbols
|
|
25
|
+
// against.
|
|
26
|
+
const FILE_HEADER = /^\+\+\+ b\/(.+)$/;
|
|
27
|
+
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
28
|
+
/** path -> new-side line ranges the diff's hunks actually cover, in the
|
|
29
|
+
* order they appear. A path present in the diff with every hunk a pure
|
|
30
|
+
* deletion maps to an empty array, deliberately -- "touched, but nothing
|
|
31
|
+
* to show on the new side" is a real, distinct answer from "not in this
|
|
32
|
+
* diff at all" (absent from the map entirely). */
|
|
33
|
+
export function parseDiffHunks(diffText) {
|
|
34
|
+
const result = new Map();
|
|
35
|
+
let currentPath = null;
|
|
36
|
+
for (const line of diffText.split('\n')) {
|
|
37
|
+
const fileMatch = FILE_HEADER.exec(line);
|
|
38
|
+
if (fileMatch) {
|
|
39
|
+
currentPath = fileMatch[1];
|
|
40
|
+
if (!result.has(currentPath))
|
|
41
|
+
result.set(currentPath, []);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (currentPath === null)
|
|
45
|
+
continue;
|
|
46
|
+
const hunkMatch = HUNK_HEADER.exec(line);
|
|
47
|
+
if (hunkMatch) {
|
|
48
|
+
const start = Number(hunkMatch[1]);
|
|
49
|
+
const count = hunkMatch[2] === undefined ? 1 : Number(hunkMatch[2]);
|
|
50
|
+
if (count > 0) {
|
|
51
|
+
result.get(currentPath).push({ startLine: start, endLine: start + count - 1 });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
/** Every path the diff's `+++ b/<path>` headers name, in order of first
|
|
58
|
+
* appearance. */
|
|
59
|
+
export function touchedFiles(diffText) {
|
|
60
|
+
return [...parseDiffHunks(diffText).keys()];
|
|
61
|
+
}
|
|
62
|
+
function overlaps(a, startLine, endLine) {
|
|
63
|
+
return a.startLine <= endLine && startLine <= a.endLine;
|
|
64
|
+
}
|
|
65
|
+
/** True iff `[startLine, endLine]` overlaps at least one of the diff's
|
|
66
|
+
* new-side hunk ranges for `path`. A path not in the diff, or present
|
|
67
|
+
* with only pure-deletion hunks, never overlaps anything. */
|
|
68
|
+
export function rangeTouchedByDiff(hunks, path, startLine, endLine) {
|
|
69
|
+
const ranges = hunks.get(path);
|
|
70
|
+
if (!ranges)
|
|
71
|
+
return false;
|
|
72
|
+
return ranges.some((r) => overlaps(r, startLine, endLine));
|
|
73
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SourceAccess } from './source-access.js';
|
|
2
|
+
/** The default, fs-rooted `SourceAccess` -- reads a checked-out worktree
|
|
3
|
+
* directly. This is what every stage with a local checkout (pump,
|
|
4
|
+
* preflight, eval, and the CI Action's own `actions/checkout`, per the
|
|
5
|
+
* operator's confirmation) supplies; nothing in this library performs a
|
|
6
|
+
* fetch of its own. */
|
|
7
|
+
export declare function fsSourceAccess(root: string): SourceAccess;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile as fsReadFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
const IGNORED_DIRS = new Set(['.git', 'node_modules', 'dist', 'build']);
|
|
4
|
+
async function walk(root, dir, out) {
|
|
5
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
6
|
+
for (const entry of entries) {
|
|
7
|
+
if (entry.isDirectory()) {
|
|
8
|
+
if (IGNORED_DIRS.has(entry.name))
|
|
9
|
+
continue;
|
|
10
|
+
await walk(root, join(dir, entry.name), out);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (entry.isFile()) {
|
|
14
|
+
out.push(relative(root, join(dir, entry.name)).split(sep).join('/'));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The default, fs-rooted `SourceAccess` -- reads a checked-out worktree
|
|
19
|
+
* directly. This is what every stage with a local checkout (pump,
|
|
20
|
+
* preflight, eval, and the CI Action's own `actions/checkout`, per the
|
|
21
|
+
* operator's confirmation) supplies; nothing in this library performs a
|
|
22
|
+
* fetch of its own. */
|
|
23
|
+
export function fsSourceAccess(root) {
|
|
24
|
+
return {
|
|
25
|
+
async listFiles() {
|
|
26
|
+
const out = [];
|
|
27
|
+
await walk(root, root, out);
|
|
28
|
+
return out.sort();
|
|
29
|
+
},
|
|
30
|
+
async readFile(path) {
|
|
31
|
+
try {
|
|
32
|
+
return await fsReadFile(join(root, path), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/parseReply.js
CHANGED
|
@@ -75,6 +75,51 @@ function taggedLines(lines, marker) {
|
|
|
75
75
|
.filter((line) => line.trimStart().startsWith(marker))
|
|
76
76
|
.map((line) => line.trimStart().slice(marker.length).trim());
|
|
77
77
|
}
|
|
78
|
+
const ALL_MARKERS = [VERDICT_MARKER, FINDING_MARKER, REASON_MARKER, RULING_MARKER];
|
|
79
|
+
function isMarkerLine(line) {
|
|
80
|
+
const trimmed = line.trimStart();
|
|
81
|
+
return ALL_MARKERS.some((marker) => trimmed.startsWith(marker));
|
|
82
|
+
}
|
|
83
|
+
/** An indented line, a list item, or a numbered item: a continuation even after a blank. */
|
|
84
|
+
function isIndentedOrListed(line) {
|
|
85
|
+
return /^[ \t]+\S/.test(line) || /^[-*+]\s/.test(line) || /^\d+[.)]\s/.test(line);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Every `FINDING:` line with the lines that continue it. A finding runs from
|
|
89
|
+
* its marker line until the next marker line (FINDING:, VERDICT:, REASON:,
|
|
90
|
+
* RULING:), a markdown heading, or the end of the reply; a blank line ends it
|
|
91
|
+
* unless the next non-blank line is indented or a list item (so sub-bullets
|
|
92
|
+
* after a blank still belong to it). Without this a finding whose sentence
|
|
93
|
+
* wraps, or that is followed by sub-bullets, was cut at its first line.
|
|
94
|
+
*/
|
|
95
|
+
function findingBlocks(lines) {
|
|
96
|
+
const blocks = [];
|
|
97
|
+
for (let i = 0; i < lines.length; i++) {
|
|
98
|
+
const line = lines[i] ?? '';
|
|
99
|
+
if (!line.trimStart().startsWith(FINDING_MARKER))
|
|
100
|
+
continue;
|
|
101
|
+
const raw = line.trimStart().slice(FINDING_MARKER.length).trim();
|
|
102
|
+
const detail = [];
|
|
103
|
+
let pendingBlanks = 0;
|
|
104
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
105
|
+
const next = lines[j] ?? '';
|
|
106
|
+
if (isMarkerLine(next) || /^#{1,6}\s/.test(next))
|
|
107
|
+
break;
|
|
108
|
+
if (next.trim() === '') {
|
|
109
|
+
pendingBlanks++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (pendingBlanks > 0 && !isIndentedOrListed(next))
|
|
113
|
+
break;
|
|
114
|
+
for (let b = 0; b < pendingBlanks; b++)
|
|
115
|
+
detail.push('');
|
|
116
|
+
pendingBlanks = 0;
|
|
117
|
+
detail.push(next.replace(/\s+$/, ''));
|
|
118
|
+
}
|
|
119
|
+
blocks.push({ raw, detail: detail.join('\n') });
|
|
120
|
+
}
|
|
121
|
+
return blocks;
|
|
122
|
+
}
|
|
78
123
|
function cannotRun(lens, reason) {
|
|
79
124
|
return { lens, outcome: 'could_not_run', findings: [], reason };
|
|
80
125
|
}
|
|
@@ -147,16 +192,22 @@ function parseReplyBase(lens, text) {
|
|
|
147
192
|
if (verdict === '') {
|
|
148
193
|
return cannotRun(lens, `reply carried an empty ${VERDICT_MARKER} line`);
|
|
149
194
|
}
|
|
150
|
-
const
|
|
151
|
-
const findings =
|
|
152
|
-
.filter((
|
|
153
|
-
.map((
|
|
195
|
+
const blocks = findingBlocks(lines);
|
|
196
|
+
const findings = blocks
|
|
197
|
+
.filter((block) => block.raw !== '')
|
|
198
|
+
.map((block) => {
|
|
199
|
+
const finding = severityOf(lens, block.raw);
|
|
200
|
+
return block.detail === '' ? finding : { ...finding, detail: block.detail };
|
|
201
|
+
})
|
|
154
202
|
.filter((finding) => finding.summary !== '');
|
|
155
|
-
const emptyFindings =
|
|
203
|
+
const emptyFindings = blocks.length - findings.length;
|
|
156
204
|
const blockingFindings = findings.filter((finding) => finding.blocking);
|
|
157
205
|
if (verdict === APPROVED_WORD || verdict === REJECTED_WORD) {
|
|
206
|
+
// An optional one-line account of the judgement, shown in the report next to the verdict.
|
|
207
|
+
const note = taggedLines(lines, REASON_MARKER).find((reason) => reason !== '');
|
|
208
|
+
const withNote = note === undefined ? {} : { reason: note };
|
|
158
209
|
if (blockingFindings.length > 0) {
|
|
159
|
-
return { lens, outcome: 'rejected', findings };
|
|
210
|
+
return { lens, outcome: 'rejected', findings, ...withNote };
|
|
160
211
|
}
|
|
161
212
|
if (emptyFindings > 0) {
|
|
162
213
|
return cannotRun(lens, `reply carried ${emptyFindings} empty ${FINDING_MARKER} line(s) — named nothing ` +
|
|
@@ -168,7 +219,7 @@ function parseReplyBase(lens, text) {
|
|
|
168
219
|
`finding(s); a rejection requires at least one blocking finding`
|
|
169
220
|
: `reply said ${REJECTED_WORD} without naming a finding`);
|
|
170
221
|
}
|
|
171
|
-
return { lens, outcome: 'approved', findings };
|
|
222
|
+
return { lens, outcome: 'approved', findings, ...withNote };
|
|
172
223
|
}
|
|
173
224
|
if (verdict === CANNOT_RUN_WORD) {
|
|
174
225
|
const reasons = taggedLines(lines, REASON_MARKER).filter((reason) => reason !== '');
|