@vintasoftware/pr-review-canvas 0.2.0 → 0.3.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 (90) hide show
  1. package/README.md +9 -3
  2. package/docs/reference.md +99 -99
  3. package/package.json +12 -3
  4. package/pr-review.config.example.yml +4 -4
  5. package/skills/pr-review-canvas/SKILL.md +8 -1
  6. package/src/acpx/acpx.ts +21 -4
  7. package/src/acpx/events.ts +5 -1
  8. package/src/acpx/ndjson.ts +3 -1
  9. package/src/acpx/preflight.ts +5 -1
  10. package/src/canvas/export.ts +11 -2
  11. package/src/canvas/zip.ts +4 -1
  12. package/src/chat/chat-manager.ts +16 -3
  13. package/src/chat/context.ts +10 -3
  14. package/src/chat/seed.ts +3 -1
  15. package/src/cli.ts +12 -2
  16. package/src/commands.ts +41 -9
  17. package/src/config.ts +15 -3
  18. package/src/contract/comments.ts +3 -1
  19. package/src/contract/generation-context.ts +3 -1
  20. package/src/contract/review-artifact.ts +14 -8
  21. package/src/contract/state.ts +3 -1
  22. package/src/git/diff-collector.ts +2 -1
  23. package/src/git/git.ts +9 -4
  24. package/src/github/capabilities.ts +5 -1
  25. package/src/github/comments.ts +6 -3
  26. package/src/github/gh.ts +18 -3
  27. package/src/github/post-comment.ts +4 -1
  28. package/src/github/pr.ts +4 -1
  29. package/src/github/threads.ts +5 -1
  30. package/src/project-config.ts +12 -9
  31. package/src/prompt-files.ts +4 -3
  32. package/src/review/doctor.ts +9 -2
  33. package/src/review/normalize.ts +3 -1
  34. package/src/review/prepare.ts +24 -8
  35. package/src/review/prompt.ts +8 -2
  36. package/src/review/publish.ts +15 -3
  37. package/src/review/trim-caps.ts +10 -6
  38. package/src/review/validate.ts +65 -13
  39. package/src/server/app.ts +12 -4
  40. package/src/server/bundle.ts +17 -4
  41. package/src/server/context.ts +14 -2
  42. package/src/server/errors.ts +26 -2
  43. package/src/server/html.ts +2 -2
  44. package/src/server/node-server.ts +4 -2
  45. package/src/server/routes/api.ts +29 -5
  46. package/src/server/routes/chat-routes.ts +30 -9
  47. package/src/server/routes/pages.ts +3 -1
  48. package/src/server/routes/review-routes.ts +19 -4
  49. package/src/server/sse.ts +3 -1
  50. package/src/store/atomic-json.ts +5 -1
  51. package/src/store/canvas-store.ts +20 -6
  52. package/src/store/derived-store.ts +5 -1
  53. package/src/store/pr-store.ts +2 -1
  54. package/src/store/state-store.ts +2 -1
  55. package/static/brand.svg +19 -0
  56. package/static/js/api.js +15 -3
  57. package/static/js/app.js +8 -2
  58. package/static/js/chat-panel.js +93 -0
  59. package/static/js/chat.js +38 -15
  60. package/static/js/composer.js +6 -3
  61. package/static/js/diagram.js +2 -1
  62. package/static/js/diff-decorations.js +5 -3
  63. package/static/js/diff-renderer.js +6 -1
  64. package/static/js/dom.js +5 -7
  65. package/static/js/empty-state.js +3 -1
  66. package/static/js/header.js +1 -1
  67. package/static/js/interactions.js +55 -15
  68. package/static/js/keyboard.js +7 -2
  69. package/static/js/layers.js +23 -11
  70. package/static/js/links.js +8 -2
  71. package/static/js/nav.js +5 -2
  72. package/static/js/overview.js +32 -4
  73. package/static/js/points.js +3 -2
  74. package/static/js/progress.js +2 -1
  75. package/static/js/proposed-comment.js +4 -1
  76. package/static/js/quick-questions.js +2 -1
  77. package/static/js/regenerate.js +4 -1
  78. package/static/js/signoff.js +6 -2
  79. package/static/styles/base.css +16 -6
  80. package/static/styles/chat-panel.css +81 -0
  81. package/static/styles/chat-tools.css +28 -0
  82. package/static/styles/chat.css +1 -1
  83. package/static/styles/commands.css +4 -4
  84. package/static/styles/diff.css +1 -1
  85. package/static/styles/header.css +18 -4
  86. package/static/styles/layout.css +4 -4
  87. package/static/styles/responsive.css +1 -15
  88. package/static/styles/review.css +24 -3
  89. package/static/styles/skin-github.css +93 -100
  90. package/static/styles.css +13 -12
package/src/commands.ts CHANGED
@@ -63,7 +63,12 @@ export function printErrorEnvelope(io: CliIo, code: ErrorCode, message: string,
63
63
  }
64
64
 
65
65
  function isParseArgsError(err: unknown): err is Error {
66
- return err instanceof Error && 'code' in err && typeof err.code === 'string' && err.code.startsWith('ERR_PARSE_ARGS')
66
+ return (
67
+ err instanceof Error &&
68
+ 'code' in err &&
69
+ typeof err.code === 'string' &&
70
+ err.code.startsWith('ERR_PARSE_ARGS')
71
+ )
67
72
  }
68
73
 
69
74
  /** Prints the envelope for any failure and picks the exit code. */
@@ -156,7 +161,12 @@ export function parsePrepareTarget(values: { pr?: string; base?: string; head?:
156
161
  export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
157
162
  const { values } = parseArgs({
158
163
  args: argv,
159
- options: { pr: { type: 'string' }, base: { type: 'string' }, head: { type: 'string' }, force: { type: 'boolean' } },
164
+ options: {
165
+ pr: { type: 'string' },
166
+ base: { type: 'string' },
167
+ head: { type: 'string' },
168
+ force: { type: 'boolean' },
169
+ },
160
170
  strict: true,
161
171
  })
162
172
  const target = parsePrepareTarget(values)
@@ -194,7 +204,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
194
204
  })
195
205
  const file = positionals[0]
196
206
  if (file === undefined || positionals.length > 1) {
197
- throw new UsageError('validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>')
207
+ throw new UsageError(
208
+ 'validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>'
209
+ )
198
210
  }
199
211
  if (values.canvas === undefined) {
200
212
  throw new UsageError('validate needs --canvas <dir> (the directory prepare printed)')
@@ -202,7 +214,11 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
202
214
  const context = await readContext(path.resolve(values.canvas))
203
215
  const text = await readText(path.resolve(file))
204
216
  if (text === null) {
205
- throw new PublishError('NOT_FOUND', `${file} does not exist`, 'pass the model.json or review.json to check')
217
+ throw new PublishError(
218
+ 'NOT_FOUND',
219
+ `${file} does not exist`,
220
+ 'pass the model.json or review.json to check'
221
+ )
206
222
  }
207
223
  const fixed = values.fix === true ? await fixTitles(path.resolve(file), text, context) : { text, trims: [] }
208
224
  const report = await validateFile(ctx, parseModelText(fixed.text, path.basename(file)), context)
@@ -214,7 +230,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
214
230
  if (trim.outcome === 'fixed') {
215
231
  io.stdout(`fixed ${trim.where}: "${trim.from}" -> "${trim.to}"`)
216
232
  } else {
217
- io.stdout(`unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`)
233
+ io.stdout(
234
+ `unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`
235
+ )
218
236
  }
219
237
  }
220
238
  if (report.ok) {
@@ -277,7 +295,9 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
277
295
  })
278
296
  const canvasDir = positionals[0]
279
297
  if (canvasDir === undefined || positionals.length > 1) {
280
- throw new UsageError('publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>')
298
+ throw new UsageError(
299
+ 'publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>'
300
+ )
281
301
  }
282
302
  if (values.agent === undefined || values.agent === '') {
283
303
  throw new UsageError('publish needs --agent <id>')
@@ -312,7 +332,11 @@ export interface InstallSkillEnv {
312
332
  export async function runInstallSkill(env: InstallSkillEnv, argv: string[], io: CliIo): Promise<number> {
313
333
  const { values } = parseArgs({
314
334
  args: argv,
315
- options: { 'claude-dir': { type: 'string' }, 'codex-dir': { type: 'string' }, force: { type: 'boolean' } },
335
+ options: {
336
+ 'claude-dir': { type: 'string' },
337
+ 'codex-dir': { type: 'string' },
338
+ force: { type: 'boolean' },
339
+ },
316
340
  strict: true,
317
341
  })
318
342
  const resolve = (flag: string | undefined, fallback: string): string =>
@@ -357,7 +381,11 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
357
381
  strict: true,
358
382
  })
359
383
  const target = await resolveHead(ctx, values)
360
- const result = await exportCanvas(ctx, { headSha: target.headSha, prNumber: target.prNumber, out: values.out })
384
+ const result = await exportCanvas(ctx, {
385
+ headSha: target.headSha,
386
+ prNumber: target.prNumber,
387
+ out: values.out,
388
+ })
361
389
  printJson(io, result)
362
390
  io.stderr(`drag ${result.path} into the pull request description or a comment`)
363
391
  return EXIT.ok
@@ -375,7 +403,11 @@ async function readZipFile(zipPath: string, shown: string): Promise<Uint8Array>
375
403
  } catch {
376
404
  throw new PublishError('NOT_FOUND', `${shown} does not exist`, 'pass the canvas zip to import')
377
405
  }
378
- const tooLarge = new AppError('CANVAS_TOO_LARGE', `${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`, 413)
406
+ const tooLarge = new AppError(
407
+ 'CANVAS_TOO_LARGE',
408
+ `${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
409
+ 413
410
+ )
379
411
  try {
380
412
  // One byte past the cap is read, so a file of exactly the cap still fits and anything longer
381
413
  // is refused without the rest of it ever being in memory.
package/src/config.ts CHANGED
@@ -47,7 +47,11 @@ export async function resolveRepoRoot(git: Git): Promise<string> {
47
47
  return await git.topLevel()
48
48
  } catch (err) {
49
49
  if (err instanceof GitError) {
50
- throw new ConfigError('NOT_A_REPO', 'not inside a git repository', 'run from a clone or pass --repo <dir>')
50
+ throw new ConfigError(
51
+ 'NOT_A_REPO',
52
+ 'not inside a git repository',
53
+ 'run from a clone or pass --repo <dir>'
54
+ )
51
55
  }
52
56
  throw err
53
57
  }
@@ -72,7 +76,11 @@ export function parseGithubRemote(url: string): Repo | null {
72
76
  export async function resolveGithubRepo(git: Git): Promise<Repo> {
73
77
  const url = await git.remoteUrl('origin')
74
78
  if (url === null) {
75
- throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', 'add one that points at GitHub')
79
+ throw new ConfigError(
80
+ 'NO_ORIGIN',
81
+ 'the repository has no "origin" remote',
82
+ 'add one that points at GitHub'
83
+ )
76
84
  }
77
85
  const repo = parseGithubRemote(url)
78
86
  if (repo === null) {
@@ -90,7 +98,11 @@ export function parseChatOverrides(flags: ServeFlags): SettingsOverrides {
90
98
  const overrides: SettingsOverrides = {}
91
99
  if (flags.agent !== undefined) {
92
100
  if (!isChatAgent(flags.agent)) {
93
- throw new ConfigError('BAD_REQUEST', `unknown chat agent: ${flags.agent}`, 'use --agent claude or --agent codex')
101
+ throw new ConfigError(
102
+ 'BAD_REQUEST',
103
+ `unknown chat agent: ${flags.agent}`,
104
+ 'use --agent claude or --agent codex'
105
+ )
94
106
  }
95
107
  overrides.agent = flags.agent
96
108
  }
@@ -79,7 +79,9 @@ export const PostCommentInputSchema = z.discriminatedUnion('kind', [
79
79
  export type PostCommentInput = z.infer<typeof PostCommentInputSchema>
80
80
 
81
81
  /** The posted comment: a review comment for inline and reply, an issue comment for PR-level. */
82
- export type PostCommentResult = { kind: 'review'; comment: ReviewComment } | { kind: 'issue'; comment: IssueComment }
82
+ export type PostCommentResult =
83
+ | { kind: 'review'; comment: ReviewComment }
84
+ | { kind: 'issue'; comment: IssueComment }
83
85
 
84
86
  // The proposed-comment block a chat answer can carry. The parser lives in
85
87
  // static/js/proposed-comment.js so the browser loads the same code without a bundler.
@@ -57,7 +57,9 @@ export const GenerationContextSchema = z.object({
57
57
  }),
58
58
  /** The globs that make a file a test here. Defaulted, so a context written before this
59
59
  * field existed still reads. */
60
- tests: z.object({ patterns: z.array(z.string().min(1)) }).default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
60
+ tests: z
61
+ .object({ patterns: z.array(z.string().min(1)) })
62
+ .default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
61
63
  /** At most `generation.smallPrHunks` hunks: one layer unless concerns differ, fewer annotations. */
62
64
  smallPr: z.boolean(),
63
65
  /** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
@@ -48,7 +48,9 @@ function mapCaps(fn: (key: keyof TextCaps) => number): TextCaps {
48
48
  }
49
49
 
50
50
  /** The project config may raise or lower a cap; the result is what the validator and the prompt use. */
51
- export function effectiveCaps(overrides: { [K in keyof TextCaps]?: number | undefined } | undefined): TextCaps {
51
+ export function effectiveCaps(
52
+ overrides: { [K in keyof TextCaps]?: number | undefined } | undefined
53
+ ): TextCaps {
52
54
  return mapCaps(key => overrides?.[key] ?? TEXT_CAPS[key])
53
55
  }
54
56
 
@@ -162,13 +164,17 @@ function text(caps: Caps, key: keyof TextCaps): z.ZodString {
162
164
 
163
165
  function textOrEmpty(caps: Caps, key: keyof TextCaps): z.ZodString {
164
166
  const visibleCap = caps[key] / HARD_CAP_FACTOR
165
- const diagrams = key === 'summary' || key === 'rationale'
166
- ? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
167
- : ''
168
- return z.string().max(caps[key]).meta({
169
- description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
170
- 'x-visibleMaxLength': visibleCap,
171
- })
167
+ const diagrams =
168
+ key === 'summary' || key === 'rationale'
169
+ ? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
170
+ : ''
171
+ return z
172
+ .string()
173
+ .max(caps[key])
174
+ .meta({
175
+ description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
176
+ 'x-visibleMaxLength': visibleCap,
177
+ })
172
178
  }
173
179
 
174
180
  export function testEntrySchema(caps: Caps) {
@@ -25,7 +25,9 @@ export const PrStateSchema = z.object({
25
25
  */
26
26
  reviewedHeadSha: z.string().optional(),
27
27
  hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
28
- posted: z.array(z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })),
28
+ posted: z.array(
29
+ z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
30
+ ),
29
31
  dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
30
32
  chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
31
33
  updatedAt: z.string(),
@@ -79,7 +79,8 @@ export function parseBlock(lines: string[]): Omit<CollectedFile, 'key'> | null {
79
79
  return null
80
80
  }
81
81
 
82
- const isBinary = header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
82
+ const isBinary =
83
+ header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
83
84
  let status: FileStatus
84
85
  if (renameFrom !== null) {
85
86
  status = 'renamed'
package/src/git/git.ts CHANGED
@@ -62,10 +62,15 @@ interface ExecResult {
62
62
  /** Runs git with an argument array; never a shell. */
63
63
  export function execGit(cwd: string, args: string[]): Promise<ExecResult> {
64
64
  return new Promise(resolve => {
65
- execFile('git', args, { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
66
- const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
67
- resolve({ stdout, stderr: stderr.toString('utf8'), code })
68
- })
65
+ execFile(
66
+ 'git',
67
+ args,
68
+ { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 },
69
+ (error, stdout, stderr) => {
70
+ const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
71
+ resolve({ stdout, stderr: stderr.toString('utf8'), code })
72
+ }
73
+ )
69
74
  })
70
75
  }
71
76
 
@@ -10,7 +10,11 @@ const RepoBodySchema = z.object({
10
10
  })
11
11
 
12
12
  /** What a page assumes before the probe answers: posting is tried and GitHub decides. */
13
- export const UNKNOWN_CAPABILITIES: Capabilities = { canComment: 'unknown', tokenKind: 'unprobed', login: null }
13
+ export const UNKNOWN_CAPABILITIES: Capabilities = {
14
+ canComment: 'unknown',
15
+ tokenKind: 'unprobed',
16
+ login: null,
17
+ }
14
18
 
15
19
  export const SCOPE_HINT = 'gh auth refresh -h github.com -s repo'
16
20
 
@@ -110,9 +110,12 @@ export async function fetchComments(
110
110
  fetchAllPages(gh, `${base}/issues/${number}/comments`),
111
111
  ])
112
112
  const reviews = (await fetchAllPages(gh, `${base}/pulls/${number}/reviews`)).flatMap(raw => {
113
- const review = GhIssueCommentSchema.omit({ created_at: true }).extend({
114
- submitted_at: z.string().nullable().optional(), state: z.string(),
115
- }).parse(raw)
113
+ const review = GhIssueCommentSchema.omit({ created_at: true })
114
+ .extend({
115
+ submitted_at: z.string().nullable().optional(),
116
+ state: z.string(),
117
+ })
118
+ .parse(raw)
116
119
  if (!review.submitted_at || review.state === 'PENDING') return []
117
120
  return [{ ...mapIssueComment({ ...review, created_at: review.submitted_at }), state: review.state }]
118
121
  })
package/src/github/gh.ts CHANGED
@@ -140,14 +140,24 @@ export function createGitHubClient(exec: GhExec = execGh): GitHubClient {
140
140
  }
141
141
  const r = await exec(args)
142
142
  if (r.code !== 0) {
143
- throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
143
+ throw new GitHubApiError(
144
+ path,
145
+ r.missingBinary ? 'gh: command not found' : r.stderr,
146
+ r.code,
147
+ r.missingBinary
148
+ )
144
149
  }
145
150
  return JSON.parse(r.stdout) as unknown
146
151
  },
147
152
  apiWithHeaders: async path => {
148
153
  const r = await exec(['api', '-i', '--method', 'GET', path])
149
154
  if (r.code !== 0) {
150
- throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
155
+ throw new GitHubApiError(
156
+ path,
157
+ r.missingBinary ? 'gh: command not found' : r.stderr,
158
+ r.code,
159
+ r.missingBinary
160
+ )
151
161
  }
152
162
  return parseIncludedResponse(r.stdout)
153
163
  },
@@ -155,7 +165,12 @@ export function createGitHubClient(exec: GhExec = execGh): GitHubClient {
155
165
  // The payload goes over stdin, so no comment text ever appears in an argument list.
156
166
  const r = await exec(['api', '--method', 'POST', path, '--input', '-'], { input: JSON.stringify(body) })
157
167
  if (r.code !== 0) {
158
- throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
168
+ throw new GitHubApiError(
169
+ path,
170
+ r.missingBinary ? 'gh: command not found' : r.stderr,
171
+ r.code,
172
+ r.missingBinary
173
+ )
159
174
  }
160
175
  return JSON.parse(r.stdout) as unknown
161
176
  },
@@ -81,7 +81,10 @@ export function commentRequest(
81
81
  return { path: `${base}/pulls/${number}/comments`, body }
82
82
  }
83
83
  case 'reply':
84
- return { path: `${base}/pulls/${number}/comments/${input.inReplyToId}/replies`, body: { body: input.body } }
84
+ return {
85
+ path: `${base}/pulls/${number}/comments/${input.inReplyToId}/replies`,
86
+ body: { body: input.body },
87
+ }
85
88
  case 'issue':
86
89
  return { path: `${base}/issues/${number}/comments`, body: { body: input.body } }
87
90
  }
package/src/github/pr.ts CHANGED
@@ -100,7 +100,10 @@ export function prBaseRef(number: number): string {
100
100
  * it is local once the base was fetched). Today's base tip would contain the PR and give an
101
101
  * empty diff.
102
102
  */
103
- export async function fetchPrRefs(git: Git, meta: PrMeta): Promise<{ headSha: string; mergeBaseSha: string }> {
103
+ export async function fetchPrRefs(
104
+ git: Git,
105
+ meta: PrMeta
106
+ ): Promise<{ headSha: string; mergeBaseSha: string }> {
104
107
  await git.fetch('origin', [
105
108
  `+pull/${meta.number}/head:${prHeadRef(meta.number)}`,
106
109
  `+refs/heads/${meta.baseRef}:${prBaseRef(meta.number)}`,
@@ -33,7 +33,11 @@ export const THREADS_QUERY = `query($owner: String!, $name: String!, $number: In
33
33
  * The ids of every review comment that sits in a resolved thread. REST has no resolved flag,
34
34
  * so this one GraphQL query is joined to the REST comments by id.
35
35
  */
36
- export async function fetchResolvedCommentIds(gh: GitHubClient, repo: Repo, number: number): Promise<Set<number>> {
36
+ export async function fetchResolvedCommentIds(
37
+ gh: GitHubClient,
38
+ repo: Repo,
39
+ number: number
40
+ ): Promise<Set<number>> {
37
41
  const resolved = new Set<number>()
38
42
  let after: string | null = null
39
43
  for (;;) {
@@ -36,14 +36,16 @@ const capsShape = {
36
36
  diagram: cap(),
37
37
  } satisfies Record<keyof TextCaps, z.ZodOptional<z.ZodNumber>>
38
38
 
39
- export const PromptOverridesSchema = z.object({
40
- 'generation-format.md': z.string().min(1).optional(),
41
- 'generation-strict.md': z.string().min(1).optional(),
42
- 'generation-surfacing.md': z.string().min(1).optional(),
43
- 'quality-standards.md': z.string().min(1).optional(),
44
- 'layering-guidance.md': z.string().min(1).optional(),
45
- 'chat-seed.md': z.string().min(1).optional(),
46
- }).strict()
39
+ export const PromptOverridesSchema = z
40
+ .object({
41
+ 'generation-format.md': z.string().min(1).optional(),
42
+ 'generation-strict.md': z.string().min(1).optional(),
43
+ 'generation-surfacing.md': z.string().min(1).optional(),
44
+ 'quality-standards.md': z.string().min(1).optional(),
45
+ 'layering-guidance.md': z.string().min(1).optional(),
46
+ 'chat-seed.md': z.string().min(1).optional(),
47
+ })
48
+ .strict()
47
49
  export type PromptOverrides = z.infer<typeof PromptOverridesSchema>
48
50
 
49
51
  export const ProjectConfigSchema = z.object({
@@ -119,7 +121,8 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
119
121
  const generation: ProjectConfig['generation'] = {
120
122
  mode: user.generation?.mode ?? DEFAULT_PROJECT_CONFIG.generation.mode,
121
123
  maxRepairRounds: user.generation?.maxRepairRounds ?? DEFAULT_PROJECT_CONFIG.generation.maxRepairRounds,
122
- inlineDiffMaxLines: user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
124
+ inlineDiffMaxLines:
125
+ user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
123
126
  smallPrHunks: user.generation?.smallPrHunks ?? DEFAULT_PROJECT_CONFIG.generation.smallPrHunks,
124
127
  }
125
128
  if (user.generation?.caps !== undefined) {
@@ -15,9 +15,10 @@ export async function loadPromptFile(
15
15
  project?: ProjectPrompts
16
16
  ): Promise<string> {
17
17
  const override = project?.overrides?.[name]
18
- const file = project !== undefined && override !== undefined
19
- ? path.resolve(project.repoRoot, override)
20
- : path.join(dir, name)
18
+ const file =
19
+ project !== undefined && override !== undefined
20
+ ? path.resolve(project.repoRoot, override)
21
+ : path.join(dir, name)
21
22
  try {
22
23
  return await readFile(file, 'utf8')
23
24
  } catch (cause) {
@@ -88,7 +88,11 @@ export async function checkSkill(
88
88
  }
89
89
  }
90
90
  if (stale.length > 0) {
91
- return { ok: false, detail: `outdated or modified skill: ${stale.join(', ')}`, hint: 'run `pr-review install-skill`' }
91
+ return {
92
+ ok: false,
93
+ detail: `outdated or modified skill: ${stale.join(', ')}`,
94
+ hint: 'run `pr-review install-skill`',
95
+ }
92
96
  }
93
97
  if (found.length === 0) {
94
98
  return {
@@ -113,7 +117,10 @@ async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
113
117
  }
114
118
 
115
119
  /** Runs core checks and, with allChecks, checks acpx for AI Chat. */
116
- export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: boolean } = {}): Promise<DoctorReport> {
120
+ export async function runDoctorChecks(
121
+ deps: DoctorDeps,
122
+ options: { allChecks?: boolean } = {}
123
+ ): Promise<DoctorReport> {
117
124
  let repoRoot: string | null = null
118
125
  let git: DoctorCheck
119
126
  try {
@@ -195,7 +195,9 @@ export function artifactToModelOutput(artifact: ReviewArtifact): ModelOutput {
195
195
  summary: artifact.summary,
196
196
  layers: artifact.layers.map(layer => {
197
197
  const { id: _id, risk, files, ...rest } = layer
198
- const modelRisk = risk.filter(r => r.source === 'model').map(r => ({ label: r.label, reason: r.reason ?? '' }))
198
+ const modelRisk = risk
199
+ .filter(r => r.source === 'model')
200
+ .map(r => ({ label: r.label, reason: r.reason ?? '' }))
199
201
  const out: ModelLayer = { ...rest, files: files.map(({ isTest: _isTest, ...f }) => f) }
200
202
  if (modelRisk.length > 0) {
201
203
  out.risk = modelRisk
@@ -37,7 +37,12 @@ async function resolvePr(ctx: AppContext, number: number, log: PrepareOptions['l
37
37
  }
38
38
 
39
39
  /** A change set before a PR exists: the refs must already be in the clone. */
40
- async function resolveRefs(ctx: AppContext, base: string, head: string, log: PrepareOptions['log']): Promise<Pr> {
40
+ async function resolveRefs(
41
+ ctx: AppContext,
42
+ base: string,
43
+ head: string,
44
+ log: PrepareOptions['log']
45
+ ): Promise<Pr> {
41
46
  log('fetch-refs')
42
47
  const headSha = await ctx.git.revParse(head)
43
48
  const mergeBaseSha = await ctx.git.mergeBase(base, headSha)
@@ -93,7 +98,11 @@ async function clearCanvasDir(canvasDir: string): Promise<void> {
93
98
  }
94
99
  }
95
100
 
96
- export async function prepare(ctx: AppContext, target: PrepareTarget, opts: PrepareOptions): Promise<PrepareResult> {
101
+ export async function prepare(
102
+ ctx: AppContext,
103
+ target: PrepareTarget,
104
+ opts: PrepareOptions
105
+ ): Promise<PrepareResult> {
97
106
  const pr =
98
107
  target.kind === 'pr'
99
108
  ? await resolvePr(ctx, target.number, opts.log)
@@ -116,7 +125,8 @@ export async function prepare(ctx: AppContext, target: PrepareTarget, opts: Prep
116
125
  const derived = await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)
117
126
  const additions = derived.files.reduce((n, f) => n + f.additions, 0)
118
127
  const deletions = derived.files.reduce((n, f) => n + f.deletions, 0)
119
- const fullPr: Pr = target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
128
+ const fullPr: Pr =
129
+ target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
120
130
  const derivedDir = ctx.derived.derivedDir(pr.headSha)
121
131
  const config = ctx.projectConfig.config
122
132
 
@@ -152,14 +162,20 @@ export async function prepare(ctx: AppContext, target: PrepareTarget, opts: Prep
152
162
  largePr: isLargePr({ files: derived.files.length, additions, deletions }),
153
163
  preparedAt: ctx.now().toISOString(),
154
164
  }
155
- const sources = opts.promptSources ?? (await loadPromptSources(undefined, {
156
- repoRoot: ctx.config.repoRoot,
157
- overrides: ctx.projectConfig.config.prompts,
158
- }))
165
+ const sources =
166
+ opts.promptSources ??
167
+ (await loadPromptSources(undefined, {
168
+ repoRoot: ctx.config.repoRoot,
169
+ overrides: ctx.projectConfig.config.prompts,
170
+ }))
159
171
  await clearCanvasDir(canvasDir)
160
172
  await writeTextAtomic(promptPath, renderPrompt(context, derived.patches, sources))
161
173
  await writeJsonAtomic(contextPath, context)
162
174
  // The log keeps the whole history; publish counts attempts from this line on.
163
- await appendFile(path.join(canvasDir, 'publish.log'), `${context.preparedAt} prepared ${pr.headSha}\n`, 'utf8')
175
+ await appendFile(
176
+ path.join(canvasDir, 'publish.log'),
177
+ `${context.preparedAt} prepared ${pr.headSha}\n`,
178
+ 'utf8'
179
+ )
164
180
  return { ...result, status: 'prepared' }
165
181
  }
@@ -71,7 +71,9 @@ function configuredLayersMarkdown(ctx: GenerationContext): string {
71
71
  return ctx.defaultLayers
72
72
  .map((l, i) => {
73
73
  const paths =
74
- l.paths === undefined || l.paths.length === 0 ? '' : ` Path hints: ${l.paths.map(p => `\`${p}\``).join(', ')}.`
74
+ l.paths === undefined || l.paths.length === 0
75
+ ? ''
76
+ : ` Path hints: ${l.paths.map(p => `\`${p}\``).join(', ')}.`
75
77
  return `${i + 1}. \`${l.id}\` **${l.title}** — ${l.description}${paths}`
76
78
  })
77
79
  .join('\n')
@@ -196,7 +198,11 @@ export function schemaMarkdown(ctx: GenerationContext): string {
196
198
  }
197
199
 
198
200
  /** Selects one task and fills its data and format placeholders before the generator sees it. */
199
- export function renderPrompt(ctx: GenerationContext, patches: Record<string, string>, sources: PromptSources): string {
201
+ export function renderPrompt(
202
+ ctx: GenerationContext,
203
+ patches: Record<string, string>,
204
+ sources: PromptSources
205
+ ): string {
200
206
  const tokens: Record<string, string> = {
201
207
  TARGET_WORD: ctx.target.kind === 'pr' ? 'pull request' : 'change set',
202
208
  META: metaMarkdown(ctx),
@@ -75,7 +75,11 @@ async function readModel(canvasDir: string): Promise<{ raw: unknown } | { error:
75
75
  const file = path.join(canvasDir, 'model.json')
76
76
  const text = await readText(file)
77
77
  if (text === null) {
78
- throw new PublishError('NOT_FOUND', `${file} does not exist`, 'write the model output there and publish again')
78
+ throw new PublishError(
79
+ 'NOT_FOUND',
80
+ `${file} does not exist`,
81
+ 'write the model output there and publish again'
82
+ )
79
83
  }
80
84
  return parseModelText(text, 'model.json')
81
85
  }
@@ -141,7 +145,11 @@ export async function validationInput(
141
145
  }
142
146
  }
143
147
 
144
- export function buildManifest(context: GenerationContext, artifact: ReviewArtifact, version: string): CanvasManifest {
148
+ export function buildManifest(
149
+ context: GenerationContext,
150
+ artifact: ReviewArtifact,
151
+ version: string
152
+ ): CanvasManifest {
145
153
  const manifest: CanvasManifest = {
146
154
  formatVersion: 1,
147
155
  tool: { name: 'pr-review', version },
@@ -159,7 +167,11 @@ export function buildManifest(context: GenerationContext, artifact: ReviewArtifa
159
167
  return manifest
160
168
  }
161
169
 
162
- export async function publish(ctx: AppContext, canvasDir: string, opts: PublishOptions): Promise<PublishResult> {
170
+ export async function publish(
171
+ ctx: AppContext,
172
+ canvasDir: string,
173
+ opts: PublishOptions
174
+ ): Promise<PublishResult> {
163
175
  const context = await readContext(canvasDir)
164
176
  if (!opts.allowStale) {
165
177
  const head = await currentHead(ctx, context)
@@ -77,7 +77,10 @@ interface TitledOutput {
77
77
  * Titles only: a rationale or a body is prose a reader will read, and cutting it is the author's
78
78
  * call, not the tool's.
79
79
  */
80
- export function applyTitleTrims(output: unknown, caps: { layerTitle: number; pointTitle: number }): TitleTrim[] {
80
+ export function applyTitleTrims(
81
+ output: unknown,
82
+ caps: { layerTitle: number; pointTitle: number }
83
+ ): TitleTrim[] {
81
84
  const trims: TitleTrim[] = []
82
85
  const trim = (where: string, holder: { title?: string }, cap: number): void => {
83
86
  if (typeof holder.title !== 'string') {
@@ -89,11 +92,12 @@ export function applyTitleTrims(output: unknown, caps: { layerTitle: number; poi
89
92
  holder.title = trimmed
90
93
  } else if (visibleLength(holder.title) > cap) {
91
94
  const match = EXPLAINER.exec(holder.title.trim().replace(/\s+/g, ' '))
92
- const reason = match === null
93
- ? 'no explainer separator (:, —, – or -) to drop'
94
- : match.index === 0
95
- ? 'dropping the explainer would leave an empty title'
96
- : 'the title before the explainer still exceeds the cap'
95
+ const reason =
96
+ match === null
97
+ ? 'no explainer separator (:, —, – or -) to drop'
98
+ : match.index === 0
99
+ ? 'dropping the explainer would leave an empty title'
100
+ : 'the title before the explainer still exceeds the cap'
97
101
  trims.push({ outcome: 'unfixable', where, length: visibleLength(holder.title), cap, reason })
98
102
  }
99
103
  }