@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,7 +1,7 @@
1
1
  // Renders prompt.md from the selected generation template and the prepared context. The template carries
2
2
  // the prose; this module fills the `{{TOKENS}}` with data so a wording change never touches code.
3
3
  import { z } from 'zod'
4
- import { type GenerationContext, LARGE_PR } from '../contract/generation-context.js'
4
+ import { type BasisSplit, type GenerationContext, LARGE_PR } from '../contract/generation-context.js'
5
5
  import { type FileEntry, modelOutputSchema } from '../contract/review-artifact.js'
6
6
  import { labelPatch } from '../git/patch-lines.js'
7
7
  import { loadPromptFile, type ProjectPrompts } from '../prompt-files.js'
@@ -12,20 +12,48 @@ export { PROMPTS_DIR }
12
12
 
13
13
  export interface PromptSources {
14
14
  generation: Record<GenerationMode, string>
15
+ /** The same two modes, worded as an update of a basis canvas. Used when `ctx.basis` is set. */
16
+ incremental: Record<GenerationMode, string>
17
+ /**
18
+ * The judging rules of each mode: the half of the task that does not change between writing a
19
+ * canvas and updating one. Both task files of a mode end with it, so its wording has one home.
20
+ */
21
+ judging: Record<GenerationMode, string>
15
22
  format: string
16
23
  layeringGuidance: string
17
24
  qualityStandards: string
18
25
  }
19
26
 
20
27
  export async function loadPromptSources(dir = PROMPTS_DIR, project?: ProjectPrompts): Promise<PromptSources> {
21
- const [format, layeringGuidance, qualityStandards, strict, surfacing] = await Promise.all([
28
+ const [
29
+ format,
30
+ layeringGuidance,
31
+ qualityStandards,
32
+ strict,
33
+ surfacing,
34
+ strictInc,
35
+ surfacingInc,
36
+ judgingStrict,
37
+ judgingSurfacing,
38
+ ] = await Promise.all([
22
39
  loadPromptFile('generation-format.md', dir, project),
23
40
  loadPromptFile('layering-guidance.md', dir, project),
24
41
  loadPromptFile('quality-standards.md', dir, project),
25
42
  loadPromptFile('generation-strict.md', dir, project),
26
43
  loadPromptFile('generation-surfacing.md', dir, project),
44
+ loadPromptFile('generation-strict-incremental.md', dir, project),
45
+ loadPromptFile('generation-surfacing-incremental.md', dir, project),
46
+ loadPromptFile('judging-strict.md', dir, project),
47
+ loadPromptFile('judging-surfacing.md', dir, project),
27
48
  ])
28
- return { format, layeringGuidance, qualityStandards, generation: { strict, surfacing } }
49
+ return {
50
+ format,
51
+ layeringGuidance,
52
+ qualityStandards,
53
+ generation: { strict, surfacing },
54
+ incremental: { strict: strictInc, surfacing: surfacingInc },
55
+ judging: { strict: judgingStrict, surfacing: judgingSurfacing },
56
+ }
29
57
  }
30
58
 
31
59
  /** The line ranges of a hunk header; the trailing function context can hold backticks. */
@@ -197,6 +225,74 @@ export function schemaMarkdown(ctx: GenerationContext): string {
197
225
  return `\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\``
198
226
  }
199
227
 
228
+ function list(items: readonly string[], empty: string): string {
229
+ return items.length === 0 ? `_${empty}_` : items.map(i => `- \`${i}\``).join('\n')
230
+ }
231
+
232
+ function basisMarkdown(basis: BasisSplit | undefined): string {
233
+ if (basis === undefined) {
234
+ return ''
235
+ }
236
+ return [
237
+ `- Basis canvas: \`${basis.canvasSha}\``,
238
+ `- Its canvas file: \`${basis.reviewJsonPath}\` — read it for the wording you carry`,
239
+ ].join('\n')
240
+ }
241
+
242
+ function fileDeltaMarkdown(basis: BasisSplit | undefined): string {
243
+ if (basis === undefined) {
244
+ return ''
245
+ }
246
+ const f = basis.files
247
+ return [
248
+ `**Untouched** — the patch is byte-identical to the basis canvas's:\n\n${list(f.unchanged, 'none')}`,
249
+ `**Changed** — the patch differs, so every line number in it may have moved:\n\n${list(f.changed, 'none')}`,
250
+ `**New** — not in the basis canvas at all:\n\n${list(f.added, 'none')}`,
251
+ `**Gone** — in the basis canvas, not in this diff:\n\n${list(f.removed, 'none')}`,
252
+ ].join('\n\n')
253
+ }
254
+
255
+ /** The two lists the generator works from: what to copy across, and what to decide anew. */
256
+ function carriedMarkdown(basis: BasisSplit | undefined): string {
257
+ if (basis === undefined) {
258
+ return ''
259
+ }
260
+ const layers = basis.layers
261
+ .filter(l => l.status === 'carried')
262
+ .map(l => `- layer \`${l.key}\` — **${l.title}** (${l.carriedFiles.length} files, all untouched)`)
263
+ const files = basis.layers
264
+ .filter(l => l.status === 're-judged')
265
+ .flatMap(l => l.carriedFiles.map(p => `- \`${p}\`, from layer \`${l.key}\``))
266
+ const points = basis.points
267
+ .filter(p => p.status === 'carried')
268
+ .map(p => `- ${p.kind} on \`${p.path}\` — "${p.title}"`)
269
+ return [
270
+ `**Whole layers** — copy the layer with its title, rationale, decisions, checkByHand, tests, files, notes, folds, and annotations:\n\n${layers.length === 0 ? '_none_' : layers.join('\n')}`,
271
+ `**Single files of a re-judged layer** — the file is untouched, so its note, folds, and annotations still fit wherever you put the file:\n\n${files.length === 0 ? '_none_' : files.join('\n')}`,
272
+ `**Attention points** — repeat the kind, path, and title exactly, so the point keeps its identity and any dismissal the reviewer made:\n\n${points.length === 0 ? '_none_' : points.join('\n')}`,
273
+ ].join('\n\n')
274
+ }
275
+
276
+ function reJudgedMarkdown(basis: BasisSplit | undefined): string {
277
+ if (basis === undefined) {
278
+ return ''
279
+ }
280
+ const layers = basis.layers
281
+ .filter(l => l.status === 're-judged')
282
+ .map(
283
+ l =>
284
+ `- layer \`${l.key}\` — **${l.title}**; touched: ${l.reJudgedFiles.map(p => `\`${p}\``).join(', ')}`
285
+ )
286
+ const points = basis.points
287
+ .filter(p => p.status === 're-judged')
288
+ .map(p => `- ${p.kind} on \`${p.path}\` — "${p.title}"`)
289
+ return [
290
+ `**Layers** — the head touched at least one of their files, so decide the grouping, the prose, and the anchors again:\n\n${layers.length === 0 ? '_none_' : layers.join('\n')}`,
291
+ `**Attention points** — the code under them moved; keep one only if you read the new code and it still holds:\n\n${points.length === 0 ? '_none_' : points.join('\n')}`,
292
+ '**The summary and the pull-request-wide risk** are always written again: they describe the whole change set, which the new commits changed.',
293
+ ].join('\n\n')
294
+ }
295
+
200
296
  /** Selects one task and fills its data and format placeholders before the generator sees it. */
201
297
  export function renderPrompt(
202
298
  ctx: GenerationContext,
@@ -227,12 +323,23 @@ export function renderPrompt(
227
323
  TEST_PATTERNS: testPatternsMarkdown(ctx),
228
324
  SMALL_PR: smallPrMarkdown(ctx),
229
325
  MAX_REPAIR_ROUNDS: String(ctx.generation.maxRepairRounds),
326
+ BASIS: basisMarkdown(ctx.basis),
327
+ FILE_DELTA: fileDeltaMarkdown(ctx.basis),
328
+ CARRIED: carriedMarkdown(ctx.basis),
329
+ RE_JUDGED: reJudgedMarkdown(ctx.basis),
230
330
  }
231
- const template = sources.generation[ctx.generation.mode].replace('{{FORMAT}}', () => sources.format)
331
+ // A prepared basis picks the incremental wording: one prompt states one job, with no conditions.
332
+ // Both wordings end with the mode's judging rules, which are assembled first so the tokens inside
333
+ // them are filled by the one pass below.
334
+ const task = ctx.basis === undefined ? sources.generation : sources.incremental
335
+ const template = task[ctx.generation.mode]
336
+ .replace('{{JUDGING}}', () => sources.judging[ctx.generation.mode])
337
+ .replace('{{FORMAT}}', () => sources.format)
232
338
  return template.replace(/\{\{([A-Z_]+)\}\}/g, (_m, name: string) => {
233
339
  const value = tokens[name]
234
340
  if (value === undefined) {
235
- throw new Error(`generation-${ctx.generation.mode}.md uses an unknown token {{${name}}}`)
341
+ const suffix = ctx.basis === undefined ? '' : '-incremental'
342
+ throw new Error(`generation-${ctx.generation.mode}${suffix}.md uses an unknown token {{${name}}}`)
236
343
  }
237
344
  return value
238
345
  })
@@ -1,14 +1,18 @@
1
1
  // `pr-review publish`: validate model.json against context.json, normalize, and store the
2
- // canvas. Nothing is written when the report is not clean.
2
+ // canvas, then share it on the PR/MR. Invalid models are never stored or shared.
3
+ import { buildCanvasComment } from '../canvas/comment.js'
4
+ import { buildCanvasZipFor, exportCanvas } from '../canvas/export.js'
3
5
  import { appendFile } from 'node:fs/promises'
4
6
  import path from 'node:path'
5
7
  import type { CanvasManifest } from '../contract/canvas-manifest.js'
6
8
  import { type GenerationContext, GenerationContextSchema } from '../contract/generation-context.js'
7
9
  import type { Generator, ReviewArtifact } from '../contract/review-artifact.js'
10
+ import { UNCOMMITTED_STATE, resolveLocalHead } from '../git/local-target.js'
8
11
  import type { ValidationError, ValidationReport } from '../contract/validation.js'
9
- import { fetchPrMeta } from '../github/pr.js'
12
+ import { fetchPrRefs } from '../git/pr-refs.js'
10
13
  import type { AppContext } from '../server/context.js'
11
14
  import { readJson, readText } from '../store/atomic-json.js'
15
+ import { standsForHead } from './carry-over.js'
12
16
  import { normalize } from './normalize.js'
13
17
  import { coveredTestPaths, type ValidationInput, validateModelOutput } from './validate.js'
14
18
 
@@ -24,7 +28,11 @@ export interface PublishResult {
24
28
  headSha: string
25
29
  reviewJsonPath: string
26
30
  attempts: number
27
- /** Where the canvas shows once the server runs; absent for a change set without a PR. */
31
+ sharing:
32
+ | { status: 'shared'; url: string }
33
+ | { status: 'failed'; warning: string; zipPath: string }
34
+ | { status: 'local' }
35
+ /** Where the canvas shows once the server runs; absent only for a `--base/--head` change set. */
28
36
  reviewUrl?: string
29
37
  }
30
38
 
@@ -84,12 +92,31 @@ async function readModel(canvasDir: string): Promise<{ raw: unknown } | { error:
84
92
  return parseModelText(text, 'model.json')
85
93
  }
86
94
 
87
- /** The current head of the target; a push during generation makes the prepared context stale. */
88
- async function currentHead(ctx: AppContext, context: GenerationContext): Promise<string> {
95
+ /**
96
+ * The current head of the target; a push during generation makes the prepared context stale. For
97
+ * a pull request whose head moved, the head and base are fetched again, and a head whose diff is
98
+ * identical to the prepared commit's still counts as that commit. For a local target the working
99
+ * tree is snapshotted again, so an edit made while the agent worked is caught the same way a push
100
+ * is.
101
+ */
102
+ async function currentHead(
103
+ ctx: AppContext,
104
+ context: GenerationContext
105
+ ): Promise<{ headSha: string; moved: boolean }> {
89
106
  if (context.target.kind === 'pr') {
90
- return (await fetchPrMeta(ctx.gh, ctx.config.repo, context.target.number)).headSha
107
+ const { host, repo } = ctx.config
108
+ const meta = await host.fetchPrMeta(ctx.gh, repo, context.target.number)
109
+ if (meta.headSha === context.headSha || !ctx.projectConfig.config.canvas.keepForIdenticalDiff) {
110
+ return { headSha: meta.headSha, moved: meta.headSha !== context.headSha }
111
+ }
112
+ const head = await fetchPrRefs(ctx.git, host, meta)
113
+ return { headSha: head.headSha, moved: !(await standsForHead(ctx, head, context)) }
91
114
  }
92
- return ctx.git.revParse(context.target.head)
115
+ const headSha =
116
+ context.target.kind === 'local'
117
+ ? (await resolveLocalHead(ctx.git, context.target.source)).headSha
118
+ : await ctx.git.revParse(context.target.head)
119
+ return { headSha, moved: headSha !== context.headSha }
93
120
  }
94
121
 
95
122
  /** Publish runs since prepare last wrote a context (its `prepared` line), this one included. */
@@ -175,10 +202,10 @@ export async function publish(
175
202
  const context = await readContext(canvasDir)
176
203
  if (!opts.allowStale) {
177
204
  const head = await currentHead(ctx, context)
178
- if (head !== context.headSha) {
205
+ if (head.moved) {
179
206
  throw new PublishError(
180
207
  'CANVAS_STALE',
181
- `the target moved to ${head.slice(0, 7)} while this canvas was prepared for ${context.headSha.slice(0, 7)}`,
208
+ `the target moved to ${head.headSha.slice(0, 7)} while this canvas was prepared for ${context.headSha.slice(0, 7)}`,
182
209
  'run `pr-review prepare` again, or pass --allow-stale to publish for the old commit'
183
210
  )
184
211
  }
@@ -205,17 +232,42 @@ export async function publish(
205
232
  generatedAt: now,
206
233
  generator,
207
234
  testPatterns: context.tests.patterns,
235
+ basisCanvasSha: context.basis?.canvasSha,
208
236
  })
209
237
  const manifest = buildManifest(context, artifact, ctx.version)
210
- await ctx.canvases.write(context.headSha, artifact, manifest, manifest.prNumber)
238
+ // A snapshot commit is on no branch, so it must never be offered as a pull request's canvas.
239
+ const worktree = context.target.kind === 'local' && context.pr.state === UNCOMMITTED_STATE
240
+ await ctx.canvases.write(context.headSha, artifact, manifest, manifest.prNumber, { worktree })
241
+ if (worktree) {
242
+ // The snapshot ref moves with the working tree. This canvas stays, and the page reads its
243
+ // diffs from its own commit, so it gets an anchor that the next edit cannot take away.
244
+ await ctx.git.anchorCommit(context.headSha)
245
+ }
211
246
  const published: PublishResult = {
212
247
  status: 'published',
248
+ sharing: { status: 'local' },
213
249
  headSha: context.headSha,
214
250
  reviewJsonPath: path.join(ctx.canvases.canvasDir(context.headSha), 'review.json'),
215
251
  attempts,
216
252
  }
253
+ if (context.target.kind === 'local') {
254
+ published.reviewUrl = `http://localhost:${ctx.config.port}/review/${context.target.source}`
255
+ }
217
256
  if (context.target.kind === 'pr') {
218
257
  published.reviewUrl = `http://localhost:${ctx.config.port}/review/${context.target.number}`
258
+ try {
259
+ const zip = await buildCanvasZipFor(ctx, context.headSha, context.target.number)
260
+ const body = buildCanvasComment(zip, ctx.config.host.canvasCommentLimit)
261
+ const url = await ctx.config.host.shareCanvas(ctx.gh, ctx.config.repo, context.target.number, body)
262
+ published.sharing = { status: 'shared', url }
263
+ } catch (err) {
264
+ const exported = await exportCanvas(ctx, { headSha: context.headSha, prNumber: context.target.number })
265
+ published.sharing = {
266
+ status: 'failed',
267
+ warning: `Automatic canvas sharing failed: ${err instanceof Error ? err.message : String(err)}. Upload the ZIP to the ${ctx.config.host.noun} description manually.`,
268
+ zipPath: exported.path,
269
+ }
270
+ }
219
271
  }
220
272
  return published
221
273
  }
@@ -1,14 +1,15 @@
1
1
  // The same reviewed rules the page uses, so the body and the header never disagree.
2
2
  import { layerProgress } from '../../static/js/progress.js'
3
3
  import type { CommentsPayload } from '../contract/comments.js'
4
- import type { Layer, ReviewArtifact } from '../contract/review-artifact.js'
4
+ import type { Layer, Pr, ReviewArtifact } from '../contract/review-artifact.js'
5
5
  import type { PrState } from '../contract/state.js'
6
+ import type { CanvasLookup } from '../store/canvas-store.js'
6
7
 
7
8
  export const REVIEW_BODY_FOOTER = 'Reviewed with the pr-review canvas (localhost).'
8
9
 
9
10
  /**
10
11
  * Model text on one line of a list. Markdown characters are escaped so a title cannot open a
11
- * heading, a link, or a code span in the review GitHub publishes.
12
+ * heading, a link, or a code span in the published review.
12
13
  */
13
14
  export function inlineText(text: string): string {
14
15
  return text
@@ -18,11 +19,22 @@ export function inlineText(text: string): string {
18
19
  }
19
20
 
20
21
  /**
21
- * The state as it applies to one commit. Marks made on another commit, and marks whose commit
22
+ * The state as it applies to one canvas. Marks made on another canvas, and marks whose canvas
22
23
  * is unknown, describe other code, so they count for nothing here.
23
24
  */
24
- export function stateForHead(state: PrState, headSha: string): PrState {
25
- return state.reviewedHeadSha === headSha ? state : { ...state, reviewed: {} }
25
+ export function stateForCanvas(state: PrState, canvasSha: string): PrState {
26
+ return state.reviewedCanvasSha === canvasSha ? state : { ...state, reviewed: {} }
27
+ }
28
+
29
+ /**
30
+ * The commit the marks on this page describe: the commit of the canvas on the page, whether it is
31
+ * current, carried over, or read as outdated, since its diff is what the reviewer looked at. The
32
+ * head only when there is no canvas. Keyed this way, a carried-over canvas keeps its marks however
33
+ * often the head moves, and marks made on an outdated canvas never credit a later one. The page
34
+ * sends this commit back with every mark it makes, as `bundle.canvas.headSha`.
35
+ */
36
+ export function reviewedCommit(found: CanvasLookup, pr: Pr): string {
37
+ return found.status === 'missing' ? pr.headSha : found.headSha
26
38
  }
27
39
 
28
40
  /** The layers a human still has to look at. Empty means approve is allowed. */
@@ -1,4 +1,6 @@
1
- /** The exact command the user runs in Claude Code or Codex to generate a canvas for a PR. */
2
- export function buildSkillCommand(prNumber: number, opts: { force: boolean }): string {
3
- return `/pr-review-canvas ${prNumber}${opts.force ? ' --force' : ''}`
1
+ import { keyToString, type ReviewKey } from '../contract/review-key.js'
2
+
3
+ /** The exact command the user runs in Claude Code or Codex to generate a canvas for a target. */
4
+ export function buildSkillCommand(key: ReviewKey, opts: { force: boolean }): string {
5
+ return `/pr-review-canvas ${keyToString(key)}${opts.force ? ' --force' : ''}`
4
6
  }
@@ -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 {