@vintasoftware/pr-review-canvas 0.3.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 +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -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 +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- 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/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +64 -23
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -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 +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- 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/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- package/src/github/gh.ts +0 -211
|
@@ -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,49 +172,264 @@ 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()) {
|
|
85
231
|
if (assignedHunk(fold, file, hunks) === null) {
|
|
86
|
-
fail(`fold ${index + 1} must be an ordered range inside one
|
|
232
|
+
fail(`fold ${index + 1} must be an ordered range inside one chunk assigned to this file in this layer`)
|
|
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 }
|
|
@@ -217,7 +223,7 @@ function checkHunks(output: ModelOutput, index: Index, report: Report): Map<stri
|
|
|
217
223
|
for (const id of file.hunks) {
|
|
218
224
|
const hit = index.byHunkId.get(id)
|
|
219
225
|
if (hit === undefined) {
|
|
220
|
-
const hint = entry === undefined ? '' : ` (${file.path} has ${entry.hunks.length}
|
|
226
|
+
const hint = entry === undefined ? '' : ` (${file.path} has ${entry.hunks.length} chunks)`
|
|
221
227
|
report.add('HUNK_UNKNOWN', where, `${layerLabel(layer)}: ${id} does not exist${hint}`)
|
|
222
228
|
continue
|
|
223
229
|
}
|
|
@@ -269,7 +275,7 @@ function checkLayers(output: ModelOutput, report: Report): void {
|
|
|
269
275
|
keys.set(layer.key, i)
|
|
270
276
|
}
|
|
271
277
|
if (layer.files.length === 0) {
|
|
272
|
-
report.add('LAYER_EMPTY', where, `${layerLabel(layer)} has no
|
|
278
|
+
report.add('LAYER_EMPTY', where, `${layerLabel(layer)} has no chunks`)
|
|
273
279
|
}
|
|
274
280
|
})
|
|
275
281
|
// Other is optional: at most one, and last when present.
|
|
@@ -366,7 +372,7 @@ function checkRisk(output: ModelOutput, highRisk: readonly HighRiskRule[], repor
|
|
|
366
372
|
report.add(
|
|
367
373
|
'RISK_IN_OTHER',
|
|
368
374
|
where,
|
|
369
|
-
`other: carries the risk tag "${tag.label}"; move the
|
|
375
|
+
`other: carries the risk tag "${tag.label}"; move the chunks to a real layer`
|
|
370
376
|
)
|
|
371
377
|
}
|
|
372
378
|
for (const file of other.files) {
|
|
@@ -403,7 +409,7 @@ function checkAnnotations(output: ModelOutput, index: Index, report: Report): vo
|
|
|
403
409
|
report.add(
|
|
404
410
|
'ANNOTATION_OUTSIDE_HUNK',
|
|
405
411
|
where,
|
|
406
|
-
`${label} is not inside one
|
|
412
|
+
`${label} is not inside one chunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`
|
|
407
413
|
)
|
|
408
414
|
} else if (!file.hunks.includes(start.id)) {
|
|
409
415
|
report.add(
|
|
@@ -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)
|