@vintasoftware/pr-review-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Turns a validated ModelOutput into the ReviewArtifact the server stores: ids, fingerprints,
|
|
2
|
+
// isTest, config risk tags, the points that missing tests add, and the generator stamp.
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import type {
|
|
5
|
+
FileEntry,
|
|
6
|
+
Generator,
|
|
7
|
+
Layer,
|
|
8
|
+
ModelLayer,
|
|
9
|
+
ModelOutput,
|
|
10
|
+
ModelPoint,
|
|
11
|
+
Point,
|
|
12
|
+
Pr,
|
|
13
|
+
ReviewArtifact,
|
|
14
|
+
RiskTag,
|
|
15
|
+
Side,
|
|
16
|
+
TextCaps,
|
|
17
|
+
} from '../contract/review-artifact.js'
|
|
18
|
+
import { POINT_LEVELS } from '../contract/review-artifact.js'
|
|
19
|
+
import { hunkForLine } from '../git/patch-lines.js'
|
|
20
|
+
import type { HighRiskRule } from '../project-config.js'
|
|
21
|
+
import { matchesGlob } from './glob.js'
|
|
22
|
+
import { DEFAULT_TEST_PATTERNS, isTestPath } from './test-paths.js'
|
|
23
|
+
|
|
24
|
+
export interface NormalizeInput {
|
|
25
|
+
pr: Pr
|
|
26
|
+
files: readonly FileEntry[]
|
|
27
|
+
highRisk: readonly HighRiskRule[]
|
|
28
|
+
/** The caps in force; a generated `tests` point title is cut to `caps.pointTitle`. */
|
|
29
|
+
caps: TextCaps
|
|
30
|
+
generatedAt: string
|
|
31
|
+
generator: Generator
|
|
32
|
+
/** The globs that make a file a test; the project config's list, or the built-in one. */
|
|
33
|
+
testPatterns?: readonly string[] | undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Lowercase, one space between words, so a retitled point keeps its fingerprint when only spacing changed. */
|
|
37
|
+
export function normalizeTitle(title: string): string {
|
|
38
|
+
return title.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function fingerprint(point: { kind: string; path: string; title: string }): string {
|
|
42
|
+
return createHash('sha1')
|
|
43
|
+
.update(`${point.kind}\n${point.path}\n${normalizeTitle(point.title)}`)
|
|
44
|
+
.digest('hex')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function layerRisk(layer: ModelLayer, highRisk: readonly HighRiskRule[]): RiskTag[] {
|
|
48
|
+
const tags: RiskTag[] = []
|
|
49
|
+
const seen = new Set<string>()
|
|
50
|
+
for (const rule of highRisk) {
|
|
51
|
+
if (!seen.has(rule.label) && layer.files.some(f => matchesGlob(rule.pattern, f.path))) {
|
|
52
|
+
seen.add(rule.label)
|
|
53
|
+
tags.push({ label: rule.label, source: 'config' })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const tag of layer.risk ?? []) {
|
|
57
|
+
if (!seen.has(tag.label)) {
|
|
58
|
+
seen.add(tag.label)
|
|
59
|
+
tags.push({ label: tag.label, source: 'model', reason: tag.reason })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return tags
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unionRisk(layers: readonly Layer[]): RiskTag[] {
|
|
66
|
+
const out: RiskTag[] = []
|
|
67
|
+
const seen = new Set<string>()
|
|
68
|
+
for (const layer of layers) {
|
|
69
|
+
for (const tag of layer.risk) {
|
|
70
|
+
if (!seen.has(tag.label)) {
|
|
71
|
+
seen.add(tag.label)
|
|
72
|
+
out.push(tag)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toLayer(
|
|
80
|
+
layer: ModelLayer,
|
|
81
|
+
index: number,
|
|
82
|
+
highRisk: readonly HighRiskRule[],
|
|
83
|
+
testPatterns: readonly string[]
|
|
84
|
+
): Layer {
|
|
85
|
+
const { risk: _modelRisk, files, ...rest } = layer
|
|
86
|
+
return {
|
|
87
|
+
...rest,
|
|
88
|
+
id: `layer-${index + 1}`,
|
|
89
|
+
risk: layerRisk(layer, highRisk),
|
|
90
|
+
files: files.map(f => ({ ...f, isTest: isTestPath(f.path, testPatterns) })),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The layer that lists the hunk covering `path:line`, or undefined when no hunk covers it. */
|
|
95
|
+
export function layerIdForLine(
|
|
96
|
+
layers: readonly Layer[],
|
|
97
|
+
files: readonly FileEntry[],
|
|
98
|
+
path: string,
|
|
99
|
+
side: Side,
|
|
100
|
+
line: number
|
|
101
|
+
): string | undefined {
|
|
102
|
+
const entry = files.find(f => f.path === path)
|
|
103
|
+
const hunk = entry === undefined ? null : hunkForLine(entry.hunks, side, line)
|
|
104
|
+
if (hunk === null) {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
return layers.find(l => l.files.some(f => f.hunks.includes(hunk.id)))?.id
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type Unassigned = Omit<Point, 'id'>
|
|
111
|
+
|
|
112
|
+
function modelPoint(p: ModelPoint, layers: readonly Layer[], files: readonly FileEntry[]): Unassigned {
|
|
113
|
+
const point: Unassigned = { ...p, fingerprint: fingerprint(p), origin: 'model' }
|
|
114
|
+
const layerId = layerIdForLine(layers, files, p.path, p.side ?? 'new', p.line)
|
|
115
|
+
if (layerId !== undefined) {
|
|
116
|
+
point.layerId = layerId
|
|
117
|
+
}
|
|
118
|
+
return point
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One `tests` point per missing test entry, anchored on the layer's first hunk. */
|
|
122
|
+
function testPoints(layer: Layer, files: readonly FileEntry[], titleCap: number): Unassigned[] {
|
|
123
|
+
const out: Unassigned[] = []
|
|
124
|
+
const first = layer.files[0]
|
|
125
|
+
const entry = first === undefined ? undefined : files.find(f => f.path === first.path)
|
|
126
|
+
const hunk = entry?.hunks.find(h => h.id === first?.hunks[0])
|
|
127
|
+
if (first === undefined || hunk === undefined) {
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
const side: Side = hunk.newLines === 0 ? 'old' : 'new'
|
|
131
|
+
const line = side === 'new' ? hunk.newStart : hunk.oldStart
|
|
132
|
+
for (const t of layer.tests) {
|
|
133
|
+
if (t.status !== 'missing') {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
const title = t.behavior.length > titleCap ? `${t.behavior.slice(0, titleCap - 1)}…` : t.behavior
|
|
137
|
+
const note = t.note === undefined ? '' : ` ${t.note}`
|
|
138
|
+
const point: Unassigned = {
|
|
139
|
+
kind: 'tests',
|
|
140
|
+
level: 'check',
|
|
141
|
+
title,
|
|
142
|
+
path: first.path,
|
|
143
|
+
line,
|
|
144
|
+
side,
|
|
145
|
+
body: `The layer "${layer.title}" lists this behavior without a test.${note}`,
|
|
146
|
+
fingerprint: fingerprint({ kind: 'tests', path: first.path, title }),
|
|
147
|
+
origin: 'tests',
|
|
148
|
+
layerId: layer.id,
|
|
149
|
+
}
|
|
150
|
+
out.push(point)
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const LEVEL_ORDER = new Map(POINT_LEVELS.map((l, i) => [l, i]))
|
|
156
|
+
|
|
157
|
+
function sortPoints(points: Unassigned[]): Point[] {
|
|
158
|
+
const sorted = [...points].sort((a, b) => {
|
|
159
|
+
const level = (LEVEL_ORDER.get(a.level) ?? 0) - (LEVEL_ORDER.get(b.level) ?? 0)
|
|
160
|
+
if (level !== 0) {
|
|
161
|
+
return level
|
|
162
|
+
}
|
|
163
|
+
return a.path.localeCompare(b.path) || a.line - b.line
|
|
164
|
+
})
|
|
165
|
+
return sorted.map((p, i) => ({ ...p, id: `p-${i + 1}` }))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function normalize(output: ModelOutput, input: NormalizeInput): ReviewArtifact {
|
|
169
|
+
const testPatterns = input.testPatterns ?? DEFAULT_TEST_PATTERNS
|
|
170
|
+
const layers = output.layers.map((l, i) => toLayer(l, i, input.highRisk, testPatterns))
|
|
171
|
+
const points = [
|
|
172
|
+
...output.points.map(p => modelPoint(p, layers, input.files)),
|
|
173
|
+
...layers.flatMap(l => testPoints(l, input.files, input.caps.pointTitle)),
|
|
174
|
+
]
|
|
175
|
+
return {
|
|
176
|
+
version: 1,
|
|
177
|
+
pr: input.pr,
|
|
178
|
+
files: [...input.files],
|
|
179
|
+
summary: output.summary,
|
|
180
|
+
risk: unionRisk(layers),
|
|
181
|
+
layers,
|
|
182
|
+
points: sortPoints(points),
|
|
183
|
+
generatedAt: input.generatedAt,
|
|
184
|
+
generator: input.generator,
|
|
185
|
+
source: 'local',
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The model's view of a stored artifact: server-assigned fields removed, config risk dropped,
|
|
191
|
+
* `tests` points dropped (publish recreates them). `pr-review validate review.json` uses this.
|
|
192
|
+
*/
|
|
193
|
+
export function artifactToModelOutput(artifact: ReviewArtifact): ModelOutput {
|
|
194
|
+
return {
|
|
195
|
+
summary: artifact.summary,
|
|
196
|
+
layers: artifact.layers.map(layer => {
|
|
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 ?? '' }))
|
|
199
|
+
const out: ModelLayer = { ...rest, files: files.map(({ isTest: _isTest, ...f }) => f) }
|
|
200
|
+
if (modelRisk.length > 0) {
|
|
201
|
+
out.risk = modelRisk
|
|
202
|
+
}
|
|
203
|
+
return out
|
|
204
|
+
}),
|
|
205
|
+
points: artifact.points
|
|
206
|
+
.filter(p => p.origin === 'model')
|
|
207
|
+
.map(({ id: _id, fingerprint: _fp, origin: _origin, layerId: _layerId, ...p }) => p),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// `pr-review prepare`: fetch the target, build derived/, and write prompt.md + context.json into
|
|
2
|
+
// the canvas directory. The agent reads those two files; publish reads context.json back.
|
|
3
|
+
import { appendFile, readdir, rm } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { type GenerationContext, isLargePr, type PrepareTarget } from '../contract/generation-context.js'
|
|
6
|
+
import { effectiveCaps, LIMITS, type Pr } from '../contract/review-artifact.js'
|
|
7
|
+
import { fetchPrMeta, fetchPrRefs, toPr } from '../github/pr.js'
|
|
8
|
+
import type { AppContext } from '../server/context.js'
|
|
9
|
+
import { readText, writeJsonAtomic, writeTextAtomic } from '../store/atomic-json.js'
|
|
10
|
+
import { loadPromptSources, type PromptSources, renderPrompt } from './prompt.js'
|
|
11
|
+
|
|
12
|
+
export interface PrepareOptions {
|
|
13
|
+
force: boolean
|
|
14
|
+
/** Progress lines: `fetch-pr`, `fetch-refs`, `collect-diffs`, `prompt`. */
|
|
15
|
+
log: (phase: string) => void
|
|
16
|
+
promptSources?: PromptSources
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PrepareResult {
|
|
20
|
+
canvasDir: string
|
|
21
|
+
headSha: string
|
|
22
|
+
mergeBaseSha: string
|
|
23
|
+
promptPath: string
|
|
24
|
+
contextPath: string
|
|
25
|
+
status: 'prepared' | 'exists'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The PR meta, live from GitHub, with the head and base refs fetched into the local clone. */
|
|
29
|
+
async function resolvePr(ctx: AppContext, number: number, log: PrepareOptions['log']): Promise<Pr> {
|
|
30
|
+
log('fetch-pr')
|
|
31
|
+
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, number)
|
|
32
|
+
log('fetch-refs')
|
|
33
|
+
const shas = await fetchPrRefs(ctx.git, meta)
|
|
34
|
+
const pr = toPr(meta, ctx.config.repo, shas)
|
|
35
|
+
await ctx.prs.writePr(pr)
|
|
36
|
+
return pr
|
|
37
|
+
}
|
|
38
|
+
|
|
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> {
|
|
41
|
+
log('fetch-refs')
|
|
42
|
+
const headSha = await ctx.git.revParse(head)
|
|
43
|
+
const mergeBaseSha = await ctx.git.mergeBase(base, headSha)
|
|
44
|
+
const author = await ctx.git.commitAuthor(headSha)
|
|
45
|
+
const repo = ctx.config.repo
|
|
46
|
+
return {
|
|
47
|
+
number: null,
|
|
48
|
+
title: head,
|
|
49
|
+
body: '',
|
|
50
|
+
author,
|
|
51
|
+
url: `https://github.com/${repo.owner}/${repo.name}/compare/${base}...${head}`,
|
|
52
|
+
state: 'pre-pr',
|
|
53
|
+
draft: false,
|
|
54
|
+
updatedAt: ctx.now().toISOString(),
|
|
55
|
+
baseRef: base,
|
|
56
|
+
headRef: head,
|
|
57
|
+
headSha,
|
|
58
|
+
mergeBaseSha,
|
|
59
|
+
additions: 0,
|
|
60
|
+
deletions: 0,
|
|
61
|
+
changedFiles: 0,
|
|
62
|
+
repo,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readRulebook(ctx: AppContext): Promise<GenerationContext['rulebook']> {
|
|
67
|
+
const rel = ctx.projectConfig.config.rulebook
|
|
68
|
+
if (rel === undefined) {
|
|
69
|
+
return { path: null, text: null }
|
|
70
|
+
}
|
|
71
|
+
const text = await readText(path.resolve(ctx.config.repoRoot, rel))
|
|
72
|
+
return { path: rel, text }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Files an earlier run left behind (model.json, agent logs, scratch) go. The published canvas
|
|
77
|
+
* (review.json, manifest.json) stays until publish replaces it atomically, so the page and the
|
|
78
|
+
* index keep serving the old canvas while the agent works; derived/ and the log stay too.
|
|
79
|
+
*/
|
|
80
|
+
export const KEPT_ON_PREPARE = new Set(['derived', 'publish.log', 'review.json', 'manifest.json'])
|
|
81
|
+
|
|
82
|
+
async function clearCanvasDir(canvasDir: string): Promise<void> {
|
|
83
|
+
let names: string[]
|
|
84
|
+
try {
|
|
85
|
+
names = await readdir(canvasDir)
|
|
86
|
+
} catch {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
if (!KEPT_ON_PREPARE.has(name)) {
|
|
91
|
+
await rm(path.join(canvasDir, name), { recursive: true, force: true })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function prepare(ctx: AppContext, target: PrepareTarget, opts: PrepareOptions): Promise<PrepareResult> {
|
|
97
|
+
const pr =
|
|
98
|
+
target.kind === 'pr'
|
|
99
|
+
? await resolvePr(ctx, target.number, opts.log)
|
|
100
|
+
: await resolveRefs(ctx, target.base, target.head, opts.log)
|
|
101
|
+
const canvasDir = ctx.canvases.canvasDir(pr.headSha)
|
|
102
|
+
const promptPath = path.join(canvasDir, 'prompt.md')
|
|
103
|
+
const contextPath = path.join(canvasDir, 'context.json')
|
|
104
|
+
const result = {
|
|
105
|
+
canvasDir,
|
|
106
|
+
headSha: pr.headSha,
|
|
107
|
+
mergeBaseSha: pr.mergeBaseSha,
|
|
108
|
+
promptPath,
|
|
109
|
+
contextPath,
|
|
110
|
+
}
|
|
111
|
+
if (!opts.force && (await ctx.canvases.exists(pr.headSha))) {
|
|
112
|
+
return { ...result, status: 'exists' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
opts.log('collect-diffs')
|
|
116
|
+
const derived = await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)
|
|
117
|
+
const additions = derived.files.reduce((n, f) => n + f.additions, 0)
|
|
118
|
+
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 }
|
|
120
|
+
const derivedDir = ctx.derived.derivedDir(pr.headSha)
|
|
121
|
+
const config = ctx.projectConfig.config
|
|
122
|
+
|
|
123
|
+
opts.log('prompt')
|
|
124
|
+
const context: GenerationContext = {
|
|
125
|
+
version: 1,
|
|
126
|
+
target,
|
|
127
|
+
repo: ctx.config.repo,
|
|
128
|
+
pr: fullPr,
|
|
129
|
+
headSha: pr.headSha,
|
|
130
|
+
mergeBaseSha: pr.mergeBaseSha,
|
|
131
|
+
canvasDir,
|
|
132
|
+
paths: {
|
|
133
|
+
head: path.join(derivedDir, 'head'),
|
|
134
|
+
base: path.join(derivedDir, 'base'),
|
|
135
|
+
patches: path.join(derivedDir, 'patches'),
|
|
136
|
+
model: path.join(canvasDir, 'model.json'),
|
|
137
|
+
},
|
|
138
|
+
files: derived.files,
|
|
139
|
+
defaultLayers: config.layers,
|
|
140
|
+
rulebook: await readRulebook(ctx),
|
|
141
|
+
highRisk: config.highRisk,
|
|
142
|
+
caps: effectiveCaps(config.generation.caps),
|
|
143
|
+
limits: { ...LIMITS },
|
|
144
|
+
generation: {
|
|
145
|
+
mode: config.generation.mode,
|
|
146
|
+
maxRepairRounds: config.generation.maxRepairRounds,
|
|
147
|
+
inlineDiffMaxLines: config.generation.inlineDiffMaxLines,
|
|
148
|
+
smallPrHunks: config.generation.smallPrHunks,
|
|
149
|
+
},
|
|
150
|
+
tests: { patterns: config.tests.patterns },
|
|
151
|
+
smallPr: derived.files.reduce((n, f) => n + f.hunks.length, 0) <= config.generation.smallPrHunks,
|
|
152
|
+
largePr: isLargePr({ files: derived.files.length, additions, deletions }),
|
|
153
|
+
preparedAt: ctx.now().toISOString(),
|
|
154
|
+
}
|
|
155
|
+
const sources = opts.promptSources ?? (await loadPromptSources(undefined, {
|
|
156
|
+
repoRoot: ctx.config.repoRoot,
|
|
157
|
+
overrides: ctx.projectConfig.config.prompts,
|
|
158
|
+
}))
|
|
159
|
+
await clearCanvasDir(canvasDir)
|
|
160
|
+
await writeTextAtomic(promptPath, renderPrompt(context, derived.patches, sources))
|
|
161
|
+
await writeJsonAtomic(contextPath, context)
|
|
162
|
+
// 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')
|
|
164
|
+
return { ...result, status: 'prepared' }
|
|
165
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Renders prompt.md from the selected generation template and the prepared context. The template carries
|
|
2
|
+
// the prose; this module fills the `{{TOKENS}}` with data so a wording change never touches code.
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { type GenerationContext, LARGE_PR } from '../contract/generation-context.js'
|
|
5
|
+
import { type FileEntry, modelOutputSchema } from '../contract/review-artifact.js'
|
|
6
|
+
import { labelPatch } from '../git/patch-lines.js'
|
|
7
|
+
import { loadPromptFile, type ProjectPrompts } from '../prompt-files.js'
|
|
8
|
+
import { PROMPTS_DIR } from '../paths.js'
|
|
9
|
+
import type { GenerationMode } from '../project-config.js'
|
|
10
|
+
|
|
11
|
+
export { PROMPTS_DIR }
|
|
12
|
+
|
|
13
|
+
export interface PromptSources {
|
|
14
|
+
generation: Record<GenerationMode, string>
|
|
15
|
+
format: string
|
|
16
|
+
layersDefault: string
|
|
17
|
+
qualityStandards: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function loadPromptSources(dir = PROMPTS_DIR, project?: ProjectPrompts): Promise<PromptSources> {
|
|
21
|
+
const [format, layersDefault, qualityStandards, strict, surfacing] = await Promise.all([
|
|
22
|
+
loadPromptFile('generation-format.md', dir, project),
|
|
23
|
+
loadPromptFile('layers-default.md', dir, project),
|
|
24
|
+
loadPromptFile('quality-standards.md', dir, project),
|
|
25
|
+
loadPromptFile('generation-strict.md', dir, project),
|
|
26
|
+
loadPromptFile('generation-surfacing.md', dir, project),
|
|
27
|
+
])
|
|
28
|
+
return { format, layersDefault, qualityStandards, generation: { strict, surfacing } }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The line ranges of a hunk header; the trailing function context can hold backticks. */
|
|
32
|
+
export function hunkRange(header: string): string {
|
|
33
|
+
const m = /^(@@ [^@]*@@)/.exec(header)
|
|
34
|
+
return m?.[1] ?? header
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function manifestMarkdown(files: readonly FileEntry[]): string {
|
|
38
|
+
return files
|
|
39
|
+
.map(f => {
|
|
40
|
+
const rename = f.oldPath === undefined ? '' : ` (from \`${f.oldPath}\`)`
|
|
41
|
+
const head = `- \`${f.path}\`${rename} — ${f.status}, +${f.additions} −${f.deletions}`
|
|
42
|
+
const hunks = f.hunks.map(h => ` - \`${h.id}\` \`${hunkRange(h.header)}\``)
|
|
43
|
+
return [head, ...hunks].join('\n')
|
|
44
|
+
})
|
|
45
|
+
.join('\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Strips YAML front matter and pushes every heading down two levels, so the rulebook nests under the prompt. */
|
|
49
|
+
export function embedMarkdown(text: string): string {
|
|
50
|
+
const withoutFrontMatter = text.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
51
|
+
return withoutFrontMatter
|
|
52
|
+
.replace(/^(#{1,6}) /gm, (_m, hashes: string) => `${'#'.repeat(Math.min(hashes.length + 2, 6))} `)
|
|
53
|
+
.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function patchLineCount(patches: Record<string, string>): number {
|
|
57
|
+
return Object.values(patches).reduce((n, p) => n + (p === '' ? 0 : p.split('\n').length), 0)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function inlineDiffs(files: readonly FileEntry[], patches: Record<string, string>): string {
|
|
61
|
+
return files
|
|
62
|
+
.filter(f => (patches[f.key] ?? '') !== '')
|
|
63
|
+
.map(f => `#### \`${f.path}\`\n\n\`\`\`\`diff\n${labelPatch(f.key, patches[f.key] ?? '')}\n\`\`\`\``)
|
|
64
|
+
.join('\n\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function defaultLayersMarkdown(ctx: GenerationContext): string {
|
|
68
|
+
if (ctx.defaultLayers.length === 0) {
|
|
69
|
+
return '_The project config lists no default layers; choose the layers yourself._'
|
|
70
|
+
}
|
|
71
|
+
return ctx.defaultLayers
|
|
72
|
+
.map((l, i) => {
|
|
73
|
+
const paths =
|
|
74
|
+
l.paths === undefined || l.paths.length === 0 ? '' : ` Path hints: ${l.paths.map(p => `\`${p}\``).join(', ')}.`
|
|
75
|
+
return `${i + 1}. \`${l.id}\` **${l.title}** — ${l.description}${paths}`
|
|
76
|
+
})
|
|
77
|
+
.join('\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function capsMarkdown(ctx: GenerationContext): string {
|
|
81
|
+
const c = ctx.caps
|
|
82
|
+
return [
|
|
83
|
+
`- summary: ${c.summary} characters`,
|
|
84
|
+
`- layer title: ${c.layerTitle}`,
|
|
85
|
+
`- layer rationale: ${c.rationale}`,
|
|
86
|
+
`- layer decisions: ${c.decisions}`,
|
|
87
|
+
`- layer checkByHand: ${c.checkByHand}`,
|
|
88
|
+
`- file note and annotation text: ${c.annotation}`,
|
|
89
|
+
`- attention point title: ${c.pointTitle}`,
|
|
90
|
+
`- fold title: ${c.pointTitle}, plain text counted in full`,
|
|
91
|
+
`- attention point body: ${c.pointBody}`,
|
|
92
|
+
`- test behavior: ${c.testBehavior}`,
|
|
93
|
+
`- diagram source: ${c.diagram} characters of mermaid, counted raw`,
|
|
94
|
+
].join('\n')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function highRiskMarkdown(ctx: GenerationContext): string {
|
|
98
|
+
if (ctx.highRisk.length === 0) {
|
|
99
|
+
return '_No highRisk patterns are configured._'
|
|
100
|
+
}
|
|
101
|
+
return ctx.highRisk.map(r => `- \`${r.pattern}\` → **${r.label}**`).join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function metaMarkdown(ctx: GenerationContext): string {
|
|
105
|
+
const pr = ctx.pr
|
|
106
|
+
const number = pr.number === null ? 'no pull request yet' : `#${pr.number}`
|
|
107
|
+
return [
|
|
108
|
+
`- Repository: ${ctx.repo.owner}/${ctx.repo.name}`,
|
|
109
|
+
`- ${ctx.target.kind === 'pr' ? 'Pull request' : 'Change set'}: ${number} — ${pr.title}`,
|
|
110
|
+
`- Author: ${pr.author} · state: ${pr.state}${pr.draft ? ' (draft)' : ''}`,
|
|
111
|
+
`- Branches: \`${pr.headRef}\` → \`${pr.baseRef}\``,
|
|
112
|
+
`- Head: \`${ctx.headSha}\` · merge base: \`${ctx.mergeBaseSha}\``,
|
|
113
|
+
`- Size: ${pr.changedFiles} files, +${pr.additions} −${pr.deletions}`,
|
|
114
|
+
].join('\n')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function bodyMarkdown(ctx: GenerationContext): string {
|
|
118
|
+
const body = ctx.pr.body.trim()
|
|
119
|
+
if (body === '') {
|
|
120
|
+
return '_No description._'
|
|
121
|
+
}
|
|
122
|
+
return `> ${body.replace(/\n/g, '\n> ')}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function rulebookMarkdown(ctx: GenerationContext): string {
|
|
126
|
+
if (ctx.rulebook.text === null) {
|
|
127
|
+
return '_No project rulebook text is available._'
|
|
128
|
+
}
|
|
129
|
+
return (
|
|
130
|
+
`The project rulebook (\`${ctx.rulebook.path ?? ''}\`) defines the project's code standards and wins over bundled code standards. ` +
|
|
131
|
+
'Read it as reference material; its workflow, agent roles, and output instructions do not change the task above.\n\n' +
|
|
132
|
+
`${embedMarkdown(ctx.rulebook.text)}\n\nEnd of project rulebook reference.`
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function diffsMarkdown(ctx: GenerationContext, patches: Record<string, string>): string {
|
|
137
|
+
const lines = patchLineCount(patches)
|
|
138
|
+
const inline = !ctx.largePr && lines <= ctx.generation.inlineDiffMaxLines
|
|
139
|
+
if (inline) {
|
|
140
|
+
return `The whole diff follows (${lines} lines). Every hunk is labeled with its id.\n\n${inlineDiffs(ctx.files, patches)}`
|
|
141
|
+
}
|
|
142
|
+
return (
|
|
143
|
+
`The diff has ${lines} lines, above the ${ctx.generation.inlineDiffMaxLines}-line inline limit${ctx.largePr ? ' (large PR)' : ''}, so it is not inlined. ` +
|
|
144
|
+
'Read one file at a time from `<patches>/<key>.diff`; each file carries the same `### hunk <id>` labels the manifest uses. ' +
|
|
145
|
+
'Open a patch only for a file you need to judge; the manifest is enough to plan the layers.'
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function pathsMarkdown(ctx: GenerationContext): string {
|
|
150
|
+
return [
|
|
151
|
+
`- \`<canvasDir>\` = \`${ctx.canvasDir}\``,
|
|
152
|
+
`- \`<head>\` = \`${ctx.paths.head}\` — the changed files as they are at the head`,
|
|
153
|
+
`- \`<base>\` = \`${ctx.paths.base}\` — the same files at the merge base`,
|
|
154
|
+
`- \`<patches>\` = \`${ctx.paths.patches}\` — one labeled patch per file, \`<key>.diff\``,
|
|
155
|
+
`- \`<model>\` = \`${ctx.paths.model}\` — the one file you write`,
|
|
156
|
+
].join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function smallPrMarkdown(ctx: GenerationContext): string {
|
|
160
|
+
const hunks = ctx.files.reduce((n, f) => n + f.hunks.length, 0)
|
|
161
|
+
const limit = ctx.generation.smallPrHunks
|
|
162
|
+
if (!ctx.smallPr) {
|
|
163
|
+
return `This ${ctx.target.kind === 'pr' ? 'pull request' : 'change set'} has ${hunks} hunks, above the ${limit}-hunk small-change limit, so the layering rules above apply in full.`
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
`**Small change set.** This ${ctx.target.kind === 'pr' ? 'pull request' : 'change set'} has ${hunks} hunks, at most ${limit}, so:\n\n` +
|
|
167
|
+
'- Use one layer unless the concerns truly differ; do not split for the sake of the taxonomy.\n' +
|
|
168
|
+
'- Annotate only where the diff does not speak for itself; zero annotations is a fine answer.\n' +
|
|
169
|
+
'- Keep the summary self-contained: state the behavior change and the one relationship or decision worth understanding.'
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function largePrMarkdown(ctx: GenerationContext): string {
|
|
174
|
+
if (!ctx.largePr) {
|
|
175
|
+
return ''
|
|
176
|
+
}
|
|
177
|
+
return (
|
|
178
|
+
`\n\n**Large ${ctx.target.kind === 'pr' ? 'pull request' : 'change set'}.** ` +
|
|
179
|
+
`More than ${LARGE_PR.files} files or ${LARGE_PR.lines} changed lines, so no diff is inlined above. ` +
|
|
180
|
+
`Keep annotations to at most 6 per file and attention points to at most ${ctx.limits.maxPoints}, ` +
|
|
181
|
+
'and lean on the manifest rather than reading every patch.'
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function testPatternsMarkdown(ctx: GenerationContext): string {
|
|
186
|
+
const patterns = ctx.tests.patterns
|
|
187
|
+
if (patterns.length === 0) {
|
|
188
|
+
return 'no pattern, so no file counts as a test here'
|
|
189
|
+
}
|
|
190
|
+
return patterns.map(p => `\`${p}\``).join(', ')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function schemaMarkdown(ctx: GenerationContext): string {
|
|
194
|
+
const schema = z.toJSONSchema(modelOutputSchema(ctx.caps))
|
|
195
|
+
return `\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\``
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 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 {
|
|
200
|
+
const tokens: Record<string, string> = {
|
|
201
|
+
TARGET_WORD: ctx.target.kind === 'pr' ? 'pull request' : 'change set',
|
|
202
|
+
META: metaMarkdown(ctx),
|
|
203
|
+
HEAD_SHA: ctx.headSha,
|
|
204
|
+
MERGE_BASE_SHA: ctx.mergeBaseSha,
|
|
205
|
+
BODY: bodyMarkdown(ctx),
|
|
206
|
+
PATHS: pathsMarkdown(ctx),
|
|
207
|
+
MODEL_PATH: ctx.paths.model,
|
|
208
|
+
MANIFEST: manifestMarkdown(ctx.files),
|
|
209
|
+
DIFFS: diffsMarkdown(ctx, patches),
|
|
210
|
+
DEFAULT_LAYERS: defaultLayersMarkdown(ctx),
|
|
211
|
+
LAYERS_DEFAULT: sources.layersDefault.trim(),
|
|
212
|
+
CAPS: capsMarkdown(ctx),
|
|
213
|
+
MAX_POINTS: String(ctx.limits.maxPoints),
|
|
214
|
+
MAX_DIAGRAMS: String(ctx.limits.maxDiagramsPerLayer),
|
|
215
|
+
MAX_DIAGRAM_LINKS: String(ctx.limits.maxDiagramLinks),
|
|
216
|
+
HIGH_RISK: highRiskMarkdown(ctx),
|
|
217
|
+
RULEBOOK: rulebookMarkdown(ctx),
|
|
218
|
+
QUALITY_STANDARDS: sources.qualityStandards.trim(),
|
|
219
|
+
SCHEMA: schemaMarkdown(ctx),
|
|
220
|
+
LARGE_PR: largePrMarkdown(ctx),
|
|
221
|
+
TEST_PATTERNS: testPatternsMarkdown(ctx),
|
|
222
|
+
SMALL_PR: smallPrMarkdown(ctx),
|
|
223
|
+
MAX_REPAIR_ROUNDS: String(ctx.generation.maxRepairRounds),
|
|
224
|
+
}
|
|
225
|
+
const template = sources.generation[ctx.generation.mode].replace('{{FORMAT}}', () => sources.format)
|
|
226
|
+
return template.replace(/\{\{([A-Z_]+)\}\}/g, (_m, name: string) => {
|
|
227
|
+
const value = tokens[name]
|
|
228
|
+
if (value === undefined) {
|
|
229
|
+
throw new Error(`generation-${ctx.generation.mode}.md uses an unknown token {{${name}}}`)
|
|
230
|
+
}
|
|
231
|
+
return value
|
|
232
|
+
})
|
|
233
|
+
}
|