@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.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -1,11 +1,82 @@
1
- import type { CodeFold, FileEntry, Hunk, ModelLayer, ModelOutput } from '../contract/review-artifact.js'
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
- function assignedHunk(fold: CodeFold, file: ModelFile, hunks: readonly Hunk[]): Hunk | null {
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
- function protectedRanges(
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[] = [...file.annotations]
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 validateFileFolds(
175
+ function fileFolds(
66
176
  file: ModelFile,
67
177
  layer: ModelLayer,
68
178
  output: ModelOutput,
69
179
  hunks: readonly Hunk[]
70
- ): ValidationError[] {
71
- const errors: ValidationError[] = []
72
- const where = `layer:${layer.key}/file:${file.path}`
73
- const protectedCode = protectedRanges(file, layer, output, hunks)
74
- const folds = file.folds ?? []
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({ code: 'FOLD_INVALID', where, message: `${where}: ${message}` })
216
+ errors.push(error('FOLD_INVALID', where, message))
78
217
  }
79
218
 
80
- if (file.collapsed && protectedCode.length > 0) {
81
- fail('a file with annotations or attention points must start expanded')
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 hunk assigned to this file in this layer`)
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 earlierFolds = folds.slice(0, index)
91
- if (earlierFolds.some(earlier => rangesOverlap(fold, earlier, hunks))) {
92
- fail(`fold ${index + 1} overlaps an earlier fold or uses another coordinate side in the same hunk`)
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
- if (protectedCode.some(range => rangesOverlap(fold, range, hunks))) {
96
- fail(`fold ${index + 1} would hide an annotation or attention point`)
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
- /** Checks fold coordinates and keeps attention points and annotations visible. */
104
- export function validateFolds(output: ModelOutput, files: readonly FileEntry[]): ValidationError[] {
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
- return output.layers.flatMap(layer =>
108
- layer.files.flatMap(file => validateFileFolds(file, layer, output, byPath.get(file.path)?.hunks ?? []))
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
  }
@@ -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} hunks)`
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 hunks`)
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 hunks to a real layer`
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 hunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`
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)