@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.
Files changed (83) hide show
  1. package/README.md +27 -8
  2. package/docs/reference.md +247 -62
  3. package/package.json +1 -1
  4. package/pr-review.config.example.yml +38 -4
  5. package/prompts/generation-format.md +120 -26
  6. package/prompts/generation-strict-incremental.md +53 -0
  7. package/prompts/generation-strict.md +1 -27
  8. package/prompts/generation-surfacing-incremental.md +56 -0
  9. package/prompts/generation-surfacing.md +1 -58
  10. package/prompts/judging-strict.md +27 -0
  11. package/prompts/judging-surfacing.md +58 -0
  12. package/skills/pr-review-canvas/SKILL.md +13 -4
  13. package/src/acpx/acpx.ts +98 -5
  14. package/src/acpx/models.ts +43 -0
  15. package/src/chat/chat-manager.ts +27 -1
  16. package/src/cli.ts +58 -1
  17. package/src/commands.ts +5 -1
  18. package/src/contract/api.ts +26 -1
  19. package/src/contract/canvas-manifest.ts +5 -0
  20. package/src/contract/generation-context.ts +52 -1
  21. package/src/contract/keys.ts +1 -0
  22. package/src/contract/pending.ts +49 -0
  23. package/src/contract/review-artifact.ts +50 -7
  24. package/src/contract/reviews.ts +10 -1
  25. package/src/contract/settings.ts +5 -0
  26. package/src/contract/state.ts +23 -12
  27. package/src/contract/validation.ts +1 -0
  28. package/src/github/post-review.ts +62 -6
  29. package/src/gitlab/post-review.ts +48 -9
  30. package/src/gitlab/publish-drafts.ts +69 -0
  31. package/src/host/client.ts +3 -2
  32. package/src/host/host.ts +23 -5
  33. package/src/project-config.ts +15 -2
  34. package/src/review/carry-marks.ts +131 -0
  35. package/src/review/doctor.ts +60 -26
  36. package/src/review/incremental.ts +107 -0
  37. package/src/review/normalize.ts +14 -4
  38. package/src/review/prepare.ts +47 -0
  39. package/src/review/prompt.ts +112 -5
  40. package/src/review/publish.ts +1 -0
  41. package/src/review/test-paths.ts +44 -4
  42. package/src/review/validate-folds.ts +348 -23
  43. package/src/review/validate.ts +10 -1
  44. package/src/server/bundle.ts +14 -2
  45. package/src/server/html.ts +4 -4
  46. package/src/server/routes/chat-routes.ts +15 -6
  47. package/src/server/routes/pages.ts +4 -1
  48. package/src/server/routes/review-routes.ts +202 -42
  49. package/src/store/canvas-store.ts +3 -0
  50. package/src/store/settings-store.ts +9 -1
  51. package/src/store/state-store.ts +69 -4
  52. package/src/upgrade.ts +338 -0
  53. package/static/js/api.js +55 -1
  54. package/static/js/app.js +28 -7
  55. package/static/js/chat-panel.js +32 -9
  56. package/static/js/chat.js +27 -4
  57. package/static/js/code-folds.js +171 -44
  58. package/static/js/composer.js +109 -4
  59. package/static/js/contract-types.d.ts +4 -0
  60. package/static/js/diff-decorations.js +67 -1
  61. package/static/js/empty-state.js +17 -0
  62. package/static/js/fold-levels.js +176 -0
  63. package/static/js/header.js +36 -9
  64. package/static/js/interactions.js +273 -44
  65. package/static/js/keyboard.js +4 -1
  66. package/static/js/keys.js +12 -0
  67. package/static/js/layers.js +292 -29
  68. package/static/js/nav.js +22 -4
  69. package/static/js/pending.js +161 -0
  70. package/static/js/points.js +69 -9
  71. package/static/js/progress.js +4 -5
  72. package/static/js/quick-questions.js +15 -2
  73. package/static/js/reading-level.js +97 -0
  74. package/static/js/review-session.js +106 -27
  75. package/static/js/settings.js +53 -23
  76. package/static/js/signoff.js +75 -5
  77. package/static/js/skin.js +2 -2
  78. package/static/styles/chat-panel.css +22 -24
  79. package/static/styles/chat.css +4 -0
  80. package/static/styles/header.css +21 -0
  81. package/static/styles/pending.css +102 -0
  82. package/static/styles/review.css +4 -0
  83. package/static/styles.css +1 -0
@@ -2,11 +2,51 @@ import path from 'node:path'
2
2
  import { matchesGlob } from './glob.js'
3
3
 
4
4
  /**
5
- * What counts as a test file when the project config says nothing: the JavaScript and TypeScript
6
- * conventions this repository uses. `tests.patterns` in `pr-review.config.yml` replaces the list,
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
- export const DEFAULT_TEST_PATTERNS: readonly string[] = ['**/*.test.*', '**/*.spec.*', '**/__tests__/**']
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 { 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,23 +172,59 @@ 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()) {
@@ -87,24 +233,203 @@ function validateFileFolds(
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 chunk`)
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 }
@@ -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)
@@ -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
- // Marks made on another canvas describe other code, so the page never shows them as reviewed.
342
- state: stateForCanvas(stored, input.canvasSha),
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 }
@@ -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, type ReviewKey } from '../contract/review-key.js'
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: { prNumber: ReviewKey; owner: string; repo: string; version: string; host: Host },
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 answers 404
2
- // when the project config turns chat off, so a repository that does not want an agent in the
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
- /** Every path this file serves, so the chat-disabled check covers all of them and nothing else. */
128
- export const CHAT_ROUTE_PATTERNS = ['/settings', '/settings/*', '/prs/:n/chat', '/prs/:n/chat/*'] as const
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 these routes exist. The patterns are listed rather than `*`: this
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
- await appearanceFor(ctx, appearanceQuery(c))
77
+ appearanceForRequest(settings, appearanceQuery(c))
75
78
  )
76
79
  )
77
80
  })