@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,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 {};
@@ -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
+ }
@@ -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 findingLines = taggedLines(lines, FINDING_MARKER);
151
- const findings = findingLines
152
- .filter((raw) => raw !== '')
153
- .map((raw) => severityOf(lens, raw))
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 = findingLines.length - findings.length;
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 !== '');