@vintasoftware/pr-review-canvas 0.4.0 → 0.5.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 +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
package/src/review/test-paths.ts
CHANGED
|
@@ -2,11 +2,51 @@ import path from 'node:path'
|
|
|
2
2
|
import { matchesGlob } from './glob.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* so a Python or Go project can name `test_*.py` or `*_test.go` instead.
|
|
5
|
+
* The languages whose test classes are named `XTest` or `XTests`. Only these extensions, so a
|
|
6
|
+
* component such as `ABTest.tsx` is not taken for a test.
|
|
8
7
|
*/
|
|
9
|
-
|
|
8
|
+
const CLASS_TEST_EXTENSIONS = ['java', 'kt', 'scala', 'groovy', 'cs', 'fs', 'vb', 'swift', 'php'] as const
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The test conventions found across stacks, by directory and by file-name shape rather than by
|
|
12
|
+
* language, so a repository without a `pr-review.config.yml` still gets its tests labelled,
|
|
13
|
+
* ordered after the code they cover, and kept open at the light reading level. Directories:
|
|
14
|
+
* `__tests__`, `tests`, `test`. Names: `x.test.*` and `x.spec.*` (JS/TS), `x_test.*` (Go,
|
|
15
|
+
* Python, Elixir, Dart, Rust), `test_x.py` and `conftest.py` (pytest), `x_spec.*` (Ruby),
|
|
16
|
+
* `XTest` and `XTests` in the class-test languages above. A `spec/` directory is left out: it
|
|
17
|
+
* often holds API specs rather than tests. `tests.patterns` replaces the list.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_TEST_PATTERNS: readonly string[] = [
|
|
20
|
+
'**/__tests__/**',
|
|
21
|
+
'**/tests/**',
|
|
22
|
+
'**/test/**',
|
|
23
|
+
'**/*.test.*',
|
|
24
|
+
'**/*.spec.*',
|
|
25
|
+
'**/*_test.*',
|
|
26
|
+
'**/*_spec.*',
|
|
27
|
+
'**/test_*.py',
|
|
28
|
+
'**/conftest.py',
|
|
29
|
+
...CLASS_TEST_EXTENSIONS.flatMap(ext => [`**/*Test.${ext}`, `**/*Tests.${ext}`]),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generated content that lives beside tests: snapshots and fixtures. They are test paths, but
|
|
34
|
+
* nobody reads them line by line, so they may hide at the light level like any generated file.
|
|
35
|
+
*/
|
|
36
|
+
export const GENERATED_TEST_PATTERNS: readonly string[] = [
|
|
37
|
+
'**/__snapshots__/**',
|
|
38
|
+
'**/*.snap',
|
|
39
|
+
'**/fixtures/**',
|
|
40
|
+
'**/__fixtures__/**',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/** True for a test path a reviewer reads as written: a test path that is not a snapshot or fixture. */
|
|
44
|
+
export function isHandWrittenTest(
|
|
45
|
+
filePath: string,
|
|
46
|
+
patterns: readonly string[] = DEFAULT_TEST_PATTERNS
|
|
47
|
+
): boolean {
|
|
48
|
+
return isTestPath(filePath, patterns) && !isTestPath(filePath, GENERATED_TEST_PATTERNS)
|
|
49
|
+
}
|
|
10
50
|
|
|
11
51
|
/** True when one of the patterns matches the repo-relative path. */
|
|
12
52
|
export function isTestPath(filePath: string, patterns: readonly string[] = DEFAULT_TEST_PATTERNS): boolean {
|
|
@@ -1,11 +1,82 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CodeFold,
|
|
3
|
+
FileEntry,
|
|
4
|
+
FoldLevel,
|
|
5
|
+
Hunk,
|
|
6
|
+
ModelLayer,
|
|
7
|
+
ModelOutput,
|
|
8
|
+
} from '../contract/review-artifact.js'
|
|
9
|
+
import {
|
|
10
|
+
contains,
|
|
11
|
+
coveredRows,
|
|
12
|
+
fileRows,
|
|
13
|
+
foldLevelRank,
|
|
14
|
+
hiddenLines,
|
|
15
|
+
UNDISCUSSED,
|
|
16
|
+
} from '../contract/review-artifact.js'
|
|
2
17
|
import type { ValidationError } from '../contract/validation.js'
|
|
3
18
|
import { hunkForLine } from '../git/patch-lines.js'
|
|
19
|
+
import { DEFAULT_TEST_PATTERNS, isHandWrittenTest } from './test-paths.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rows a reviewer reads without help. Above this many diff rows outside its annotations, a file
|
|
23
|
+
* with no attention point must hide something at some level: the reading-level control exists to
|
|
24
|
+
* take routine code off the screen, and a routine file left fully open at every level defeats it.
|
|
25
|
+
* An annotation marks the lines to read; it does not make the rows around it any less routine.
|
|
26
|
+
* The same count decides when a layer has enough left open at `moderate` for `aggressive` to owe
|
|
27
|
+
* a difference.
|
|
28
|
+
*/
|
|
29
|
+
export const ROUTINE_ROWS = 20
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* At or below this many diff rows, a layer reads whole and the levels have little to add, so the
|
|
33
|
+
* layer rule about `aggressive` hiding more than `moderate` does not apply. The file rules still
|
|
34
|
+
* do: a routine file hides something, and a core file folds half, whatever the layer's size.
|
|
35
|
+
*/
|
|
36
|
+
export const SMALL_LAYER_ROWS = 100
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The most a `light` fold may cover. Light is the diff as it always looked, plus wholly generated
|
|
40
|
+
* blocks; a wider range is hand-written code dressed as generated, and hiding it by default is
|
|
41
|
+
* what the higher levels are for.
|
|
42
|
+
*/
|
|
43
|
+
export const LIGHT_FOLD_MAX_ROWS = 40
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Above this many rows, a file that stays open is a core file the reviewer reads, and by
|
|
47
|
+
* `aggressive` it must fold at least `AGGRESSIVE_MIN_HIDDEN` of the rows outside its attention
|
|
48
|
+
* points. Annotated rows count: an aggressive fold may hide them, and leaving them out would let a
|
|
49
|
+
* generator lower the bar by annotating more. Otherwise aggressive shows the same core as moderate.
|
|
50
|
+
*/
|
|
51
|
+
export const CORE_FILE_ROWS = 60
|
|
52
|
+
export const AGGRESSIVE_MIN_HIDDEN = 0.5
|
|
4
53
|
|
|
5
54
|
type SourceRange = Pick<CodeFold, 'side' | 'startLine' | 'endLine'>
|
|
55
|
+
type LeveledRange = SourceRange & { level: FoldLevel }
|
|
6
56
|
type ModelFile = ModelLayer['files'][number]
|
|
7
57
|
|
|
8
|
-
|
|
58
|
+
/** One file's folds as the rules read them, with the ranges a fold may not cover. */
|
|
59
|
+
interface FileFolds {
|
|
60
|
+
file: ModelFile
|
|
61
|
+
hunks: readonly Hunk[]
|
|
62
|
+
where: string
|
|
63
|
+
/** Attention points and the marker a missing test adds: no fold ever hides these. */
|
|
64
|
+
pinned: SourceRange[]
|
|
65
|
+
/** Only an aggressive fold may hide these, and the file they are in never collapses. */
|
|
66
|
+
annotations: SourceRange[]
|
|
67
|
+
folds: LeveledRange[]
|
|
68
|
+
collapsed: FoldLevel | null
|
|
69
|
+
/** Diff rows the file draws, the longer side of each of its hunks. */
|
|
70
|
+
rows: number
|
|
71
|
+
/**
|
|
72
|
+
* Rows outside the attention points: what a fold may be asked to hide. Annotated rows are among
|
|
73
|
+
* them, because an aggressive fold may hide them, and leaving them out would let a generator
|
|
74
|
+
* lower the bar by annotating more.
|
|
75
|
+
*/
|
|
76
|
+
judged: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assignedHunk(fold: SourceRange, file: ModelFile, hunks: readonly Hunk[]): Hunk | null {
|
|
9
80
|
if (fold.endLine < fold.startLine) {
|
|
10
81
|
return null
|
|
11
82
|
}
|
|
@@ -20,6 +91,11 @@ function assignedHunk(fold: CodeFold, file: ModelFile, hunks: readonly Hunk[]):
|
|
|
20
91
|
return start
|
|
21
92
|
}
|
|
22
93
|
|
|
94
|
+
/** Whether `outer` holds every line of `inner`, on the same side. */
|
|
95
|
+
function covers(outer: SourceRange, inner: SourceRange): boolean {
|
|
96
|
+
return outer.side === inner.side && outer.startLine <= inner.startLine && inner.endLine <= outer.endLine
|
|
97
|
+
}
|
|
98
|
+
|
|
23
99
|
/** Different coordinate sides in one hunk need the full patch to prove they are separate. */
|
|
24
100
|
function rangesOverlap(left: SourceRange, right: SourceRange, hunks: readonly Hunk[]): boolean {
|
|
25
101
|
const leftHunk = hunkForLine(hunks, left.side, left.startLine)
|
|
@@ -32,13 +108,47 @@ function rangesOverlap(left: SourceRange, right: SourceRange, hunks: readonly Hu
|
|
|
32
108
|
return left.side !== right.side || (left.startLine <= right.endLine && right.startLine <= left.endLine)
|
|
33
109
|
}
|
|
34
110
|
|
|
35
|
-
|
|
111
|
+
/**
|
|
112
|
+
* How two folds of one file sit together. Levels nest, so a fold may sit inside a fold of a higher
|
|
113
|
+
* level: at the lower level the reader opens the outer one and still finds the inner one folded.
|
|
114
|
+
* Anything else leaves the page unable to say which summary owns a row.
|
|
115
|
+
*/
|
|
116
|
+
function foldRelation(
|
|
117
|
+
left: LeveledRange,
|
|
118
|
+
right: LeveledRange,
|
|
119
|
+
hunks: readonly Hunk[]
|
|
120
|
+
): 'separate' | 'nested' | 'partial' | 'same-range' {
|
|
121
|
+
if (!rangesOverlap(left, right, hunks)) {
|
|
122
|
+
return 'separate'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (left.side !== right.side) {
|
|
126
|
+
return 'partial'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (left.startLine === right.startLine && left.endLine === right.endLine) {
|
|
130
|
+
return 'same-range'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (contains(left, right)) {
|
|
134
|
+
return foldLevelRank(right.level) < foldLevelRank(left.level) ? 'nested' : 'partial'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (contains(right, left)) {
|
|
138
|
+
return foldLevelRank(left.level) < foldLevelRank(right.level) ? 'nested' : 'partial'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return 'partial'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Ranges no fold may ever hide: attention points, and the marker a missing test adds. */
|
|
145
|
+
function pinnedRanges(
|
|
36
146
|
file: ModelFile,
|
|
37
147
|
layer: ModelLayer,
|
|
38
148
|
output: ModelOutput,
|
|
39
149
|
hunks: readonly Hunk[]
|
|
40
150
|
): SourceRange[] {
|
|
41
|
-
const ranges: SourceRange[] = [
|
|
151
|
+
const ranges: SourceRange[] = []
|
|
42
152
|
const points = output.points.filter(point => point.path === file.path)
|
|
43
153
|
|
|
44
154
|
for (const point of points) {
|
|
@@ -62,23 +172,59 @@ function protectedRanges(
|
|
|
62
172
|
return ranges
|
|
63
173
|
}
|
|
64
174
|
|
|
65
|
-
function
|
|
175
|
+
function fileFolds(
|
|
66
176
|
file: ModelFile,
|
|
67
177
|
layer: ModelLayer,
|
|
68
178
|
output: ModelOutput,
|
|
69
179
|
hunks: readonly Hunk[]
|
|
70
|
-
):
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
180
|
+
): FileFolds {
|
|
181
|
+
const pinned = pinnedRanges(file, layer, output, hunks)
|
|
182
|
+
const rows = fileRows(file, hunks)
|
|
183
|
+
return {
|
|
184
|
+
file,
|
|
185
|
+
hunks,
|
|
186
|
+
where: `layer:${layer.key}/file:${file.path}`,
|
|
187
|
+
pinned,
|
|
188
|
+
annotations: [...file.annotations],
|
|
189
|
+
folds: file.folds ?? [],
|
|
190
|
+
collapsed: file.collapsed ?? null,
|
|
191
|
+
rows,
|
|
192
|
+
judged: Math.max(rows - coveredRows(pinned), 0),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function error(code: ValidationError['code'], where: string, message: string): ValidationError {
|
|
197
|
+
return { code, where, message: `${where}: ${message}` }
|
|
198
|
+
}
|
|
75
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The rules every canvas meets, whenever it was written: fold coordinates fit the assigned hunks,
|
|
202
|
+
* folds nest or stay apart, attention points stay visible at every level, and an annotation is
|
|
203
|
+
* hidden only by an aggressive fold and never by a collapse of its file.
|
|
204
|
+
*/
|
|
205
|
+
function correctnessErrors({
|
|
206
|
+
file,
|
|
207
|
+
hunks,
|
|
208
|
+
where,
|
|
209
|
+
pinned,
|
|
210
|
+
annotations,
|
|
211
|
+
folds,
|
|
212
|
+
collapsed,
|
|
213
|
+
}: FileFolds): ValidationError[] {
|
|
214
|
+
const errors: ValidationError[] = []
|
|
76
215
|
const fail = (message: string): void => {
|
|
77
|
-
errors.push(
|
|
216
|
+
errors.push(error('FOLD_INVALID', where, message))
|
|
78
217
|
}
|
|
79
218
|
|
|
80
|
-
|
|
81
|
-
|
|
219
|
+
// A fold that covers an annotation shows the annotation's text as its title; a collapsed file
|
|
220
|
+
// shows nothing but its path. So an annotated file never collapses, at any level, and hides
|
|
221
|
+
// its routine parts with folds instead.
|
|
222
|
+
if (collapsed !== null) {
|
|
223
|
+
if (pinned.length > 0) {
|
|
224
|
+
fail('a file with attention points must start expanded')
|
|
225
|
+
} else if (annotations.length > 0) {
|
|
226
|
+
fail('a file with annotations never collapses; fold the ranges around them instead')
|
|
227
|
+
}
|
|
82
228
|
}
|
|
83
229
|
|
|
84
230
|
for (const [index, fold] of folds.entries()) {
|
|
@@ -87,24 +233,203 @@ function validateFileFolds(
|
|
|
87
233
|
continue
|
|
88
234
|
}
|
|
89
235
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
236
|
+
for (const earlier of folds.slice(0, index)) {
|
|
237
|
+
const relation = foldRelation(fold, earlier, hunks)
|
|
238
|
+
if (relation === 'same-range') {
|
|
239
|
+
fail(`fold ${index + 1} repeats the range of an earlier fold`)
|
|
240
|
+
} else if (relation === 'partial') {
|
|
241
|
+
fail(
|
|
242
|
+
`fold ${index + 1} partly overlaps an earlier fold, uses another coordinate side in the same chunk, ` +
|
|
243
|
+
'or nests inside a fold of the same or a lower level'
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (pinned.some(range => rangesOverlap(fold, range, hunks))) {
|
|
249
|
+
fail(`fold ${index + 1} would hide an attention point`)
|
|
93
250
|
}
|
|
94
251
|
|
|
95
|
-
|
|
96
|
-
|
|
252
|
+
// An aggressive fold shows the text of the annotation it hides as its title, so it hides one
|
|
253
|
+
// whole annotation at most: a second one, or a part of one, would vanish without its text.
|
|
254
|
+
const hidden = annotations.filter(range => rangesOverlap(fold, range, hunks))
|
|
255
|
+
if (hidden.length > 0 && fold.level !== 'aggressive') {
|
|
256
|
+
fail(`fold ${index + 1} would hide an annotation, which only an aggressive fold may do`)
|
|
257
|
+
} else if (hidden.length > 1) {
|
|
258
|
+
fail(
|
|
259
|
+
`fold ${index + 1} hides ${hidden.length} annotations; an aggressive fold may hide one, and shows its text`
|
|
260
|
+
)
|
|
261
|
+
} else if (hidden.some(range => !covers(fold, range))) {
|
|
262
|
+
fail(
|
|
263
|
+
`fold ${index + 1} hides part of an annotation; an aggressive fold covers a whole annotation or none of it`
|
|
264
|
+
)
|
|
97
265
|
}
|
|
98
266
|
}
|
|
99
267
|
|
|
100
268
|
return errors
|
|
101
269
|
}
|
|
102
270
|
|
|
103
|
-
/**
|
|
104
|
-
|
|
271
|
+
/**
|
|
272
|
+
* The rules a fresh generation meets for one file, about what the reading levels must be given to
|
|
273
|
+
* hide: a hand-written test file keeps its titles at light, a light fold is one generated block, a file with
|
|
274
|
+
* over `ROUTINE_ROWS` unannotated rows and no attention point hides something somewhere, and an
|
|
275
|
+
* open core file folds half of what the reviewer need not judge by aggressive. A stored canvas
|
|
276
|
+
* may predate them.
|
|
277
|
+
*/
|
|
278
|
+
function generationErrors(
|
|
279
|
+
{ file, hunks, where, pinned, annotations, folds, collapsed, rows, judged }: FileFolds,
|
|
280
|
+
isTest: boolean
|
|
281
|
+
): ValidationError[] {
|
|
282
|
+
const errors: ValidationError[] = []
|
|
283
|
+
|
|
284
|
+
// Light leaves tests exactly as the diff shows them; the bodies fold from moderate. Snapshots
|
|
285
|
+
// and fixtures are generated, so they are not held to this.
|
|
286
|
+
if (isTest && collapsed === 'light') {
|
|
287
|
+
errors.push(
|
|
288
|
+
error(
|
|
289
|
+
'FOLD_INVALID',
|
|
290
|
+
where,
|
|
291
|
+
'a test file never collapses at light; fold each test body under its title, or collapse the file at moderate'
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const [index, fold] of folds.entries()) {
|
|
297
|
+
if (isTest && fold.level === 'light') {
|
|
298
|
+
errors.push(
|
|
299
|
+
error(
|
|
300
|
+
'FOLD_INVALID',
|
|
301
|
+
where,
|
|
302
|
+
`fold ${index + 1} is light in a test file; light leaves tests as the diff shows them — ` +
|
|
303
|
+
'fold each test body under its title at moderate'
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const span = fold.endLine - fold.startLine + 1
|
|
309
|
+
if (fold.level === 'light' && span > LIGHT_FOLD_MAX_ROWS) {
|
|
310
|
+
errors.push(
|
|
311
|
+
error(
|
|
312
|
+
'FOLD_INVALID',
|
|
313
|
+
where,
|
|
314
|
+
`fold ${index + 1} at light covers ${span} rows; a light fold is one test body or one generated ` +
|
|
315
|
+
'block — fold each test under its own title, or give the range moderate'
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (collapsed !== null) {
|
|
322
|
+
return errors
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// An attention point is budgeted and always visible, so a file that carries one may stay open
|
|
326
|
+
// for it. An annotation is not a licence: it marks the lines to read, and the rows around it are
|
|
327
|
+
// as routine as in any other file.
|
|
328
|
+
const unannotated = Math.max(rows - coveredRows(annotations), 0)
|
|
329
|
+
if (pinned.length === 0 && unannotated > ROUTINE_ROWS && folds.length === 0) {
|
|
330
|
+
errors.push(
|
|
331
|
+
error(
|
|
332
|
+
'FOLD_MISSING',
|
|
333
|
+
where,
|
|
334
|
+
annotations.length === 0
|
|
335
|
+
? `${rows} changed lines with no attention point or annotation, and nothing hidden at ` +
|
|
336
|
+
'any level; collapse the file or fold its routine ranges'
|
|
337
|
+
: `${rows} changed lines, ${unannotated} of them outside its annotations, and nothing hidden ` +
|
|
338
|
+
'at any level; an annotation marks what to read — fold the routine ranges around it'
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
} else if (rows > CORE_FILE_ROWS) {
|
|
342
|
+
// Every fold hides at aggressive, whatever its own level, so what the reader is left with
|
|
343
|
+
// there is the page's own count.
|
|
344
|
+
const { hidden } = hiddenLines(file, hunks, 'aggressive', UNDISCUSSED)
|
|
345
|
+
if (hidden < Math.ceil(judged * AGGRESSIVE_MIN_HIDDEN)) {
|
|
346
|
+
errors.push(
|
|
347
|
+
error(
|
|
348
|
+
'FOLD_MISSING',
|
|
349
|
+
where,
|
|
350
|
+
`${hidden} of ${rows} changed lines hide at aggressive; a core file keeps its defining ` +
|
|
351
|
+
`lines and folds the rest, at least half of the ${judged} lines outside its attention points`
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return errors
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The rule a fresh generation meets for a layer as a whole, once the layer is bigger than
|
|
362
|
+
* `SMALL_LAYER_ROWS`: where `moderate` leaves more than `ROUTINE_ROWS` rows open outside the
|
|
363
|
+
* attention points, `aggressive` hides some of them. The prompt asks the generator to check this
|
|
364
|
+
* itself, and a layer with the same count at both levels is the one that has not. A file that
|
|
365
|
+
* fails its own rule may fail this one too; both name a fix, and the file's is the smaller one.
|
|
366
|
+
*/
|
|
367
|
+
function layerErrors(layer: ModelLayer, files: readonly FileFolds[]): ValidationError[] {
|
|
368
|
+
let rows = 0
|
|
369
|
+
let judged = 0
|
|
370
|
+
let hiddenAtModerate = 0
|
|
371
|
+
let hiddenAtAggressive = 0
|
|
372
|
+
|
|
373
|
+
for (const folds of files) {
|
|
374
|
+
rows += folds.rows
|
|
375
|
+
judged += folds.judged
|
|
376
|
+
hiddenAtModerate += hiddenLines(folds.file, folds.hunks, 'moderate', UNDISCUSSED).hidden
|
|
377
|
+
hiddenAtAggressive += hiddenLines(folds.file, folds.hunks, 'aggressive', UNDISCUSSED).hidden
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const open = Math.max(judged - hiddenAtModerate, 0)
|
|
381
|
+
if (rows <= SMALL_LAYER_ROWS || open <= ROUTINE_ROWS || hiddenAtAggressive > hiddenAtModerate) {
|
|
382
|
+
return []
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return [
|
|
386
|
+
error(
|
|
387
|
+
'FOLD_MISSING',
|
|
388
|
+
`layer:${layer.key}`,
|
|
389
|
+
`${open} changed lines stay open at moderate outside the attention points, and aggressive hides ` +
|
|
390
|
+
'none of them; aggressive leaves only the core on screen — collapse the files outside it, ' +
|
|
391
|
+
'fold the routine ranges inside it'
|
|
392
|
+
),
|
|
393
|
+
]
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Checks fold coordinates and keeps attention points visible at every level. An annotation stays
|
|
398
|
+
* visible below aggressive, and its file never collapses. A fresh generation is also held to what
|
|
399
|
+
* the reading levels must be given to hide; a stored artifact is not, because its generator may
|
|
400
|
+
* predate those rules.
|
|
401
|
+
*/
|
|
402
|
+
export function validateFolds(
|
|
403
|
+
output: ModelOutput,
|
|
404
|
+
files: readonly FileEntry[],
|
|
405
|
+
options: FoldValidationOptions = {}
|
|
406
|
+
): ValidationError[] {
|
|
105
407
|
const byPath = new Map(files.map(file => [file.path, file]))
|
|
408
|
+
const testPatterns = options.testPatterns ?? DEFAULT_TEST_PATTERNS
|
|
409
|
+
|
|
410
|
+
return output.layers.flatMap(layer => {
|
|
411
|
+
const layerFiles = layer.files.map(file =>
|
|
412
|
+
fileFolds(file, layer, output, byPath.get(file.path)?.hunks ?? [])
|
|
413
|
+
)
|
|
414
|
+
if (options.storedArtifact === true) {
|
|
415
|
+
return layerFiles.flatMap(correctnessErrors)
|
|
416
|
+
}
|
|
417
|
+
return [
|
|
418
|
+
...layerFiles.flatMap(folds => [
|
|
419
|
+
...correctnessErrors(folds),
|
|
420
|
+
...generationErrors(folds, isHandWrittenTest(folds.file.path, testPatterns)),
|
|
421
|
+
]),
|
|
422
|
+
...layerErrors(layer, layerFiles),
|
|
423
|
+
]
|
|
424
|
+
})
|
|
425
|
+
}
|
|
106
426
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
427
|
+
export interface FoldValidationOptions {
|
|
428
|
+
/** The globs that make a file a test; the built-in list when omitted. */
|
|
429
|
+
testPatterns?: readonly string[] | undefined
|
|
430
|
+
/**
|
|
431
|
+
* The output was read back from a stored `review.json`, whose generator may predate the rules
|
|
432
|
+
* about what a canvas must hide. Only the correctness rules apply then.
|
|
433
|
+
*/
|
|
434
|
+
storedArtifact?: boolean | undefined
|
|
110
435
|
}
|
package/src/review/validate.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface ValidationInput {
|
|
|
32
32
|
headPaths?: ReadonlySet<string>
|
|
33
33
|
/** The globs that make a file a test; the project config's list, or the built-in one. */
|
|
34
34
|
testPatterns?: readonly string[] | undefined
|
|
35
|
+
/**
|
|
36
|
+
* The output was read back from a stored `review.json`, whose generator may predate the rules
|
|
37
|
+
* about what a canvas must hide. Only the correctness rules apply then: coordinates, and the
|
|
38
|
+
* ranges no fold may cover. A fresh `model.json` is held to the full shape.
|
|
39
|
+
*/
|
|
40
|
+
storedArtifact?: boolean | undefined
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export type ValidationResult = ValidationReport & { output: ModelOutput | null }
|
|
@@ -560,7 +566,10 @@ export function validateModelOutput(raw: unknown, input: ValidationInput): Valid
|
|
|
560
566
|
checkTests(output, index, input.headPaths ?? new Set(), input.testPatterns ?? DEFAULT_TEST_PATTERNS, report)
|
|
561
567
|
checkRisk(output, input.highRisk, report)
|
|
562
568
|
checkAnnotations(output, index, report)
|
|
563
|
-
for (const error of validateFolds(output, input.files
|
|
569
|
+
for (const error of validateFolds(output, input.files, {
|
|
570
|
+
testPatterns: input.testPatterns ?? DEFAULT_TEST_PATTERNS,
|
|
571
|
+
storedArtifact: input.storedArtifact === true,
|
|
572
|
+
})) {
|
|
564
573
|
report.add(error.code, error.where ?? 'folds', error.message)
|
|
565
574
|
}
|
|
566
575
|
checkPoints(output, index, input.limits, report)
|
package/src/server/bundle.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { describeLocalWork, resolveLocalBase } from '../git/local-target.js'
|
|
|
15
15
|
import { fetchPrRefs } from '../git/pr-refs.js'
|
|
16
16
|
import { toPr } from '../host/pr.js'
|
|
17
17
|
import { lookupCanvas } from '../review/carry-over.js'
|
|
18
|
+
import { marksForCanvas } from '../review/carry-marks.js'
|
|
18
19
|
import { reviewedCommit, stateForCanvas } from '../review/review-body.js'
|
|
19
20
|
import { discoverSharedCanvas, discoveryFingerprint } from '../host/attachments.js'
|
|
20
21
|
import { buildSkillCommand } from '../review/skill-command.js'
|
|
@@ -320,6 +321,8 @@ async function bundleBase(
|
|
|
320
321
|
diff: ShownDiff
|
|
321
322
|
/** The commit of the canvas on screen: the marks the page shows are the ones made on it. */
|
|
322
323
|
canvasSha: string
|
|
324
|
+
/** The canvas on screen, when there is one: marks may follow the line of descent onto it. */
|
|
325
|
+
artifact: ReviewArtifact | null
|
|
323
326
|
warnings: string[]
|
|
324
327
|
}
|
|
325
328
|
): Promise<Omit<PrBundle, 'status' | 'skillCommand'>> {
|
|
@@ -333,13 +336,19 @@ async function bundleBase(
|
|
|
333
336
|
if (chatEnabled && !acpx.installed) {
|
|
334
337
|
input.warnings.push('acpx is not on PATH, so the AI Chat pane is off; install acpx to turn it on')
|
|
335
338
|
}
|
|
339
|
+
// With no canvas to read, a mark counts only when it was made on this very commit. With one,
|
|
340
|
+
// marks made on a canvas it descends from follow it wherever the diff is untouched.
|
|
341
|
+
const marks =
|
|
342
|
+
input.artifact === null
|
|
343
|
+
? { state: stateForCanvas(stored, input.canvasSha), carriedFrom: undefined }
|
|
344
|
+
: await marksForCanvas(ctx, input.artifact, input.canvasSha, stored)
|
|
336
345
|
return {
|
|
337
346
|
pr: input.pr,
|
|
338
347
|
files: input.diff.files,
|
|
339
348
|
derivable: input.diff.derivable,
|
|
340
349
|
comments: input.comments,
|
|
341
|
-
|
|
342
|
-
|
|
350
|
+
state: marks.state,
|
|
351
|
+
...(marks.carriedFrom === undefined ? {} : { marksCarriedFrom: marks.carriedFrom }),
|
|
343
352
|
capabilities,
|
|
344
353
|
chat: {
|
|
345
354
|
enabled: chatEnabled && acpx.installed,
|
|
@@ -400,6 +409,7 @@ export async function resolveLocalBundle(
|
|
|
400
409
|
capabilities: Promise.resolve(LOCAL_CAPABILITIES),
|
|
401
410
|
diff,
|
|
402
411
|
canvasSha: reviewedCommit(found, pr),
|
|
412
|
+
artifact: loaded?.artifact ?? null,
|
|
403
413
|
warnings,
|
|
404
414
|
})
|
|
405
415
|
const { warning: _screenWarning, ...screenFields } = screen
|
|
@@ -430,6 +440,7 @@ export async function resolveBundle(
|
|
|
430
440
|
capabilities,
|
|
431
441
|
diff,
|
|
432
442
|
canvasSha: pr.headSha,
|
|
443
|
+
artifact: null,
|
|
433
444
|
warnings,
|
|
434
445
|
})
|
|
435
446
|
const canvas: CanvasInfo = { headSha: pr.headSha, source: 'fixture', manifest: null }
|
|
@@ -472,6 +483,7 @@ export async function resolveBundle(
|
|
|
472
483
|
capabilities,
|
|
473
484
|
diff,
|
|
474
485
|
canvasSha: reviewedCommit(found, pr),
|
|
486
|
+
artifact: loaded?.artifact ?? null,
|
|
475
487
|
warnings,
|
|
476
488
|
})
|
|
477
489
|
const shared = sharedCanvas === null ? {} : { sharedCanvas }
|
package/src/server/html.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { html, raw } from 'hono/html'
|
|
2
2
|
import type { HtmlEscapedString } from 'hono/utils/html'
|
|
3
|
-
import type { ErrorEnvelope, HomeData } from '../contract/api.js'
|
|
4
|
-
import { keyLabel, keyToString, type LocalKey
|
|
3
|
+
import type { ErrorEnvelope, HomeData, ReviewBootstrap } from '../contract/api.js'
|
|
4
|
+
import { keyLabel, keyToString, type LocalKey } from '../contract/review-key.js'
|
|
5
5
|
import type { Appearance } from '../contract/settings.js'
|
|
6
6
|
import { type Host, publicHost } from '../host/host.js'
|
|
7
7
|
|
|
@@ -70,11 +70,11 @@ ${opts.app ? html`<script type="module" src="/static/js/app.js"></script>` : ''}
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export function reviewPage(
|
|
73
|
-
page:
|
|
73
|
+
page: Omit<ReviewBootstrap, 'host'> & { host: Host },
|
|
74
74
|
nonce: string,
|
|
75
75
|
appearance: Appearance
|
|
76
76
|
): Html {
|
|
77
|
-
const bootstrap = { ...page, host: publicHost(page.host) }
|
|
77
|
+
const bootstrap: ReviewBootstrap = { ...page, host: publicHost(page.host) }
|
|
78
78
|
const key = keyToString(bootstrap.prNumber)
|
|
79
79
|
const what =
|
|
80
80
|
typeof bootstrap.prNumber === 'number' ? `${page.host.nounShort} #${key}` : keyLabel(bootstrap.prNumber)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The AI Chat pane's routes and the personal settings behind it. Every route here
|
|
2
|
-
// when the project config turns chat off, so a repository that does not want an agent
|
|
3
|
-
// loop has no agent surface at all.
|
|
1
|
+
// The AI Chat pane's routes and the personal settings behind it. Every chat and agent route here
|
|
2
|
+
// answers 404 when the project config turns chat off, so a repository that does not want an agent
|
|
3
|
+
// in the loop has no agent surface at all; the settings file stays reachable for the reading level.
|
|
4
4
|
import { Hono, type MiddlewareHandler } from 'hono'
|
|
5
5
|
import { ChatBusyError } from '../../chat/chat-manager.js'
|
|
6
6
|
import { ChatContextError } from '../../chat/context.js'
|
|
@@ -124,13 +124,22 @@ function withDiff(artifact: ReviewArtifact, headSha: string, derived: Derived |
|
|
|
124
124
|
return { artifact, headSha, files: derived.files, patches: derived.patches }
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Every chat path this file serves, so the chat-disabled check covers all of them and nothing
|
|
129
|
+
* else. `/settings` itself stays open: the settings file also holds the reading level a review
|
|
130
|
+
* opens at, which the page reads whether or not chat is on.
|
|
131
|
+
*/
|
|
132
|
+
export const CHAT_ROUTE_PATTERNS = [
|
|
133
|
+
'/settings/agents',
|
|
134
|
+
'/settings/agents/*',
|
|
135
|
+
'/prs/:n/chat',
|
|
136
|
+
'/prs/:n/chat/*',
|
|
137
|
+
] as const
|
|
129
138
|
|
|
130
139
|
export function chatRoutes(ctx: AppContext, loader: PrLoader): Hono {
|
|
131
140
|
const api = new Hono()
|
|
132
141
|
|
|
133
|
-
// With chat off, none of
|
|
142
|
+
// With chat off, none of the chat routes exist. The patterns are listed rather than `*`: this
|
|
134
143
|
// app is mounted at the API root, so a blanket middleware would answer for every route.
|
|
135
144
|
const requireChat: MiddlewareHandler = async (_c, next) => {
|
|
136
145
|
if (!ctx.projectConfig.config.chat.enabled) {
|
|
@@ -61,6 +61,8 @@ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
|
|
|
61
61
|
'use /review/<number>, /review/branch, or /review/uncommitted'
|
|
62
62
|
)
|
|
63
63
|
}
|
|
64
|
+
// One read serves both the appearance and the reading level the canvas opens at.
|
|
65
|
+
const settings = await ctx.settings.read()
|
|
64
66
|
return c.html(
|
|
65
67
|
reviewPage(
|
|
66
68
|
{
|
|
@@ -69,9 +71,10 @@ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
|
|
|
69
71
|
repo: ctx.config.repo.name,
|
|
70
72
|
version: ctx.version,
|
|
71
73
|
host: ctx.config.host,
|
|
74
|
+
foldLevel: settings.foldLevel,
|
|
72
75
|
},
|
|
73
76
|
c.get('cspNonce'),
|
|
74
|
-
|
|
77
|
+
appearanceForRequest(settings, appearanceQuery(c))
|
|
75
78
|
)
|
|
76
79
|
)
|
|
77
80
|
})
|