@vintasoftware/pr-review-canvas 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Review marks following an incremental canvas forward. Each canvas names the one it was generated
|
|
2
|
+
// from, and that line of descent decides which of the reviewer's marks may follow — worked out here,
|
|
3
|
+
// on the reviewer's own machine, from the two canvases and the two diffs it can read itself. The
|
|
4
|
+
// published canvas is a pointer along that line and never a statement about what anyone reviewed.
|
|
5
|
+
import { reviewedId } from '../contract/keys.js'
|
|
6
|
+
import type { ReviewArtifact } from '../contract/review-artifact.js'
|
|
7
|
+
import type { PrState } from '../contract/state.js'
|
|
8
|
+
import type { AppContext } from '../server/context.js'
|
|
9
|
+
import { fileDelta } from './incremental.js'
|
|
10
|
+
|
|
11
|
+
function samePaths(a: readonly string[], b: readonly string[]): boolean {
|
|
12
|
+
const set = new Set(b)
|
|
13
|
+
return a.length === b.length && a.every(p => set.has(p))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The marks that still describe code the reviewer has seen. `marked` is the canvas they made the
|
|
18
|
+
* marks on. A file mark follows when that file is in both canvases under the same layer key and
|
|
19
|
+
* its patch is byte-identical; a layer mark follows only when the layer holds exactly the same
|
|
20
|
+
* files and none of them changed, since a layer mark claims the whole layer was read. Marks on
|
|
21
|
+
* anything else are left behind.
|
|
22
|
+
*/
|
|
23
|
+
export function carriedMarks(
|
|
24
|
+
marked: ReviewArtifact,
|
|
25
|
+
next: ReviewArtifact,
|
|
26
|
+
unchangedPaths: ReadonlySet<string>,
|
|
27
|
+
reviewed: Readonly<Record<string, true>>
|
|
28
|
+
): Record<string, true> {
|
|
29
|
+
const carried: Record<string, true> = {}
|
|
30
|
+
for (const layer of next.layers) {
|
|
31
|
+
const before = marked.layers.find(l => l.key === layer.key)
|
|
32
|
+
if (before === undefined) {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
const paths = layer.files.map(f => f.path)
|
|
36
|
+
const beforePaths = before.files.map(f => f.path)
|
|
37
|
+
for (const path of paths) {
|
|
38
|
+
const id = reviewedId(layer.key, path)
|
|
39
|
+
if (unchangedPaths.has(path) && beforePaths.includes(path) && reviewed[id] === true) {
|
|
40
|
+
carried[id] = true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const id = reviewedId(layer.key)
|
|
44
|
+
if (reviewed[id] === true && samePaths(paths, beforePaths) && paths.every(p => unchangedPaths.has(p))) {
|
|
45
|
+
carried[id] = true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return carried
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MarksForCanvas {
|
|
52
|
+
/** The stored state as it applies to this canvas: only the marks that count for it. */
|
|
53
|
+
state: PrState
|
|
54
|
+
/** The canvas the marks were made on, when any of them followed. The page says so. */
|
|
55
|
+
carriedFrom?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether the line of descent that starts at `basisSha` reaches `ancestorSha`. The canvas on screen
|
|
60
|
+
* states its own basis, so the walk starts from the artifact's own pointer; the canvases further
|
|
61
|
+
* back are read from the index, which one read already holds, so following the line costs no extra
|
|
62
|
+
* canvas reads on the page's hot path.
|
|
63
|
+
*
|
|
64
|
+
* The line matters because the reviewer's marks only move to a canvas generated from the one they
|
|
65
|
+
* were made on. It has to be a line rather than a single step: the stored commit advances only when
|
|
66
|
+
* the reviewer marks something (`setReviewed` is its only writer), so a reviewer who opens a canvas,
|
|
67
|
+
* finds it already ticked, and clicks nothing leaves their marks keyed to a canvas two or more
|
|
68
|
+
* generations back.
|
|
69
|
+
*/
|
|
70
|
+
async function descendsFrom(
|
|
71
|
+
ctx: AppContext,
|
|
72
|
+
basisSha: string | undefined,
|
|
73
|
+
ancestorSha: string
|
|
74
|
+
): Promise<boolean> {
|
|
75
|
+
const index = await ctx.canvases.readIndex()
|
|
76
|
+
const seen = new Set<string>()
|
|
77
|
+
let at = basisSha
|
|
78
|
+
while (at !== undefined && !seen.has(at)) {
|
|
79
|
+
if (at === ancestorSha) {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
seen.add(at)
|
|
83
|
+
at = index.canvases[at]?.basisCanvasSha
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The marks to show for one canvas. Marks made on it stand. Marks made on a canvas it descends
|
|
90
|
+
* from follow it for the parts of the diff those two canvases share, whatever the canvases in
|
|
91
|
+
* between did: a mark says the reviewer read one patch, so the patch they read and the patch on
|
|
92
|
+
* screen are compared directly, and a file the intervening canvases changed and changed back reads
|
|
93
|
+
* as untouched because it is. Anything else describes other code and counts for nothing, which is
|
|
94
|
+
* also what happens when this machine cannot read the older canvas or rebuild either diff.
|
|
95
|
+
*/
|
|
96
|
+
export async function marksForCanvas(
|
|
97
|
+
ctx: AppContext,
|
|
98
|
+
artifact: ReviewArtifact,
|
|
99
|
+
canvasSha: string,
|
|
100
|
+
stored: PrState
|
|
101
|
+
): Promise<MarksForCanvas> {
|
|
102
|
+
if (stored.reviewedCanvasSha === canvasSha) {
|
|
103
|
+
return { state: stored }
|
|
104
|
+
}
|
|
105
|
+
const dropped: MarksForCanvas = { state: { ...stored, reviewed: {} } }
|
|
106
|
+
const markedOn = stored.reviewedCanvasSha
|
|
107
|
+
if (markedOn === undefined || !(await descendsFrom(ctx, artifact.basisCanvasSha, markedOn))) {
|
|
108
|
+
return dropped
|
|
109
|
+
}
|
|
110
|
+
const [markedArtifact, markedManifest, manifest] = await Promise.all([
|
|
111
|
+
ctx.canvases.readArtifact(markedOn),
|
|
112
|
+
ctx.canvases.readManifest(markedOn),
|
|
113
|
+
ctx.canvases.readManifest(canvasSha),
|
|
114
|
+
])
|
|
115
|
+
if (markedArtifact === null || markedManifest === null || manifest === null) {
|
|
116
|
+
return dropped
|
|
117
|
+
}
|
|
118
|
+
const [markedDiff, diff] = await Promise.all([
|
|
119
|
+
ctx.derived.readOrBuild(markedOn, markedManifest.mergeBaseSha),
|
|
120
|
+
ctx.derived.readOrBuild(canvasSha, manifest.mergeBaseSha),
|
|
121
|
+
])
|
|
122
|
+
if (markedDiff === null || diff === null) {
|
|
123
|
+
return dropped
|
|
124
|
+
}
|
|
125
|
+
const unchanged = new Set(fileDelta(markedDiff, diff).unchanged)
|
|
126
|
+
const reviewed = carriedMarks(markedArtifact, artifact, unchanged, stored.reviewed)
|
|
127
|
+
if (Object.keys(reviewed).length === 0) {
|
|
128
|
+
return dropped
|
|
129
|
+
}
|
|
130
|
+
return { state: { ...stored, reviewed, reviewedCanvasSha: canvasSha }, carriedFrom: markedOn }
|
|
131
|
+
}
|
package/src/review/doctor.ts
CHANGED
|
@@ -37,7 +37,7 @@ export interface DoctorDeps {
|
|
|
37
37
|
acpxVersion: () => Promise<string | null>
|
|
38
38
|
/** `--data-dir` or `PR_REVIEW_DATA_DIR`; without it the dir sits next to the git common dir. */
|
|
39
39
|
dataDirOverride?: string | undefined
|
|
40
|
-
readSkill?:
|
|
40
|
+
readSkill?: ReadSkill
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function message(err: unknown): string {
|
|
@@ -60,57 +60,91 @@ async function checkDataDir(dir: string): Promise<DoctorCheck> {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export type ReadSkill = (file: string) => Promise<string | null>
|
|
64
|
+
|
|
65
|
+
const readSkillFile: ReadSkill = file => readFile(file, 'utf8')
|
|
66
|
+
|
|
67
|
+
/** One copy of the skill in the repository, next to how it compares with the bundled one. */
|
|
68
|
+
export interface SkillCopy {
|
|
69
|
+
kind: 'claude' | 'codex'
|
|
70
|
+
/** The skills directory the copy sits in, absolute. */
|
|
71
|
+
dir: string
|
|
72
|
+
/** `<dir>/pr-review-canvas`, relative to the repository. */
|
|
73
|
+
path: string
|
|
74
|
+
/** True when the copy's body or recorded hash differs from the bundled skill, or it cannot be read. */
|
|
75
|
+
stale: boolean
|
|
76
|
+
/** Why the copy could not be read, when it could not. */
|
|
77
|
+
error?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The copies of the skill in `.claude/skills` and `.agents/skills`. A directory without one is
|
|
82
|
+
* left out. Throws when the bundled skill itself cannot be read.
|
|
83
|
+
*/
|
|
84
|
+
export async function findSkillCopies(
|
|
85
|
+
repoRoot: string,
|
|
86
|
+
readSkill: ReadSkill = readSkillFile
|
|
87
|
+
): Promise<SkillCopy[]> {
|
|
88
|
+
const expected = skillContent(await readFile(path.join(SKILL_SOURCE_DIR, 'SKILL.md'), 'utf8')).hash
|
|
89
|
+
const copies: SkillCopy[] = []
|
|
90
|
+
for (const [kind, skillsDir] of [
|
|
91
|
+
['claude', CLAUDE_SKILLS_DIR],
|
|
92
|
+
['codex', CODEX_SKILLS_DIR],
|
|
93
|
+
] as const) {
|
|
94
|
+
const dir = path.join(repoRoot, skillsDir)
|
|
95
|
+
const target = path.join(dir, SKILL_NAME)
|
|
96
|
+
const rel = path.relative(repoRoot, target)
|
|
97
|
+
try {
|
|
98
|
+
const text = await readSkill(path.join(target, 'SKILL.md'))
|
|
99
|
+
if (text === null) continue
|
|
100
|
+
const { hash, frontmatter } = skillContent(text)
|
|
101
|
+
const matches = hash === expected && frontmatter.getIn(['metadata', 'body-sha256']) === expected
|
|
102
|
+
copies.push({ kind, dir, path: rel, stale: !matches })
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
105
|
+
copies.push({ kind, dir, path: rel, stale: true, error: message(err) })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return copies
|
|
110
|
+
}
|
|
111
|
+
|
|
63
112
|
/** The skill the generation flow needs, in either harness's directory. */
|
|
64
113
|
export async function checkSkill(
|
|
65
114
|
repoRoot: string | null,
|
|
66
|
-
readSkill:
|
|
115
|
+
readSkill: ReadSkill = readSkillFile
|
|
67
116
|
): Promise<DoctorCheck> {
|
|
68
117
|
if (repoRoot === null) {
|
|
69
118
|
return { ok: false, detail: 'no repository, so no skill directory to look in', hint: 'run from a clone' }
|
|
70
119
|
}
|
|
71
|
-
|
|
72
|
-
const found: string[] = []
|
|
73
|
-
const stale: string[] = []
|
|
74
|
-
let expected: string
|
|
120
|
+
let copies: SkillCopy[]
|
|
75
121
|
try {
|
|
76
|
-
|
|
122
|
+
copies = await findSkillCopies(repoRoot, readSkill)
|
|
77
123
|
} catch (err) {
|
|
78
124
|
return { ok: false, detail: message(err), hint: 'reinstall the pr-review package' }
|
|
79
125
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (text === null) continue
|
|
84
|
-
found.push(path.relative(repoRoot, target))
|
|
85
|
-
const { hash, frontmatter } = skillContent(text)
|
|
86
|
-
if (hash !== expected || frontmatter.getIn(['metadata', 'body-sha256']) !== expected) {
|
|
87
|
-
stale.push(path.relative(repoRoot, target))
|
|
88
|
-
}
|
|
89
|
-
} catch (err) {
|
|
90
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
91
|
-
stale.push(`${path.relative(repoRoot, target)}: ${message(err)}`)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
126
|
+
const stale = copies.flatMap(copy =>
|
|
127
|
+
copy.stale ? [copy.error === undefined ? copy.path : `${copy.path}: ${copy.error}`] : []
|
|
128
|
+
)
|
|
95
129
|
if (stale.length > 0) {
|
|
96
130
|
return {
|
|
97
131
|
ok: false,
|
|
98
132
|
detail: `outdated or modified skill: ${stale.join(', ')}`,
|
|
99
|
-
hint: 'run `pr-review install-skill`',
|
|
133
|
+
hint: 'run `pr-review upgrade` or `pr-review install-skill`',
|
|
100
134
|
}
|
|
101
135
|
}
|
|
102
|
-
if (
|
|
136
|
+
if (copies.length === 0) {
|
|
103
137
|
return {
|
|
104
138
|
ok: false,
|
|
105
139
|
detail: `${SKILL_NAME} is in neither ${CLAUDE_SKILLS_DIR} nor ${CODEX_SKILLS_DIR}`,
|
|
106
140
|
hint: 'run `pr-review install-skill`',
|
|
107
141
|
}
|
|
108
142
|
}
|
|
109
|
-
return { ok: true, detail:
|
|
143
|
+
return { ok: true, detail: copies.map(copy => copy.path).join(', ') }
|
|
110
144
|
}
|
|
111
145
|
|
|
112
146
|
async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
|
|
113
|
-
const hint = 'install with `npm install -g acpx` and check `acpx --version`'
|
|
147
|
+
const hint = 'install with `npm install -g acpx@latest` and check `acpx --version`'
|
|
114
148
|
try {
|
|
115
149
|
const version = (await deps.acpxVersion())?.trim()
|
|
116
150
|
return version
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Incremental generation: the basis canvas an update starts from, the per-file delta between that
|
|
2
|
+
// canvas's diff and the head's, and the split of the basis into content the head leaves untouched
|
|
3
|
+
// and content the generator has to decide anew. Everything here is a function of two diffs and one
|
|
4
|
+
// stored canvas, so `prepare` states the split instead of asking the generator to work it out.
|
|
5
|
+
import type {
|
|
6
|
+
BasisSplit,
|
|
7
|
+
BasisSplitLayer,
|
|
8
|
+
BasisSplitPoint,
|
|
9
|
+
FileDelta,
|
|
10
|
+
} from '../contract/generation-context.js'
|
|
11
|
+
import type { ReviewArtifact } from '../contract/review-artifact.js'
|
|
12
|
+
import type { Git } from '../git/git.js'
|
|
13
|
+
import type { CanvasStore } from '../store/canvas-store.js'
|
|
14
|
+
import type { Derived } from '../store/derived-store.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Which files the head changes relative to the basis canvas's diff. A file is unchanged only when
|
|
18
|
+
* its whole patch is byte-identical: line numbers, context, and all. Anything less would let a
|
|
19
|
+
* fold or an annotation of the basis land on a line it was never written for. Renames arrive as a
|
|
20
|
+
* removal and an addition, since a file's key follows its path.
|
|
21
|
+
*
|
|
22
|
+
* The two sides are matched by path, and each side's patch is read through its own key. A key is
|
|
23
|
+
* the sanitized path, and two paths can sanitize to the same key (`a-b.ts` and `a_b.ts`), which
|
|
24
|
+
* `uniqueKey` then separates by the order the files appear in that one diff. The same key can
|
|
25
|
+
* therefore name different files in two diffs; the path cannot.
|
|
26
|
+
*/
|
|
27
|
+
export function fileDelta(basis: Derived, head: Derived): FileDelta {
|
|
28
|
+
const keysByPath = (d: Derived): Map<string, string> => new Map(d.files.map(f => [f.path, f.key]))
|
|
29
|
+
const basisKeys = keysByPath(basis)
|
|
30
|
+
const headKeys = keysByPath(head)
|
|
31
|
+
const delta: FileDelta = { unchanged: [], changed: [], added: [], removed: [] }
|
|
32
|
+
for (const [path, headKey] of headKeys) {
|
|
33
|
+
const basisKey = basisKeys.get(path)
|
|
34
|
+
if (basisKey === undefined) {
|
|
35
|
+
delta.added.push(path)
|
|
36
|
+
} else if (basis.patches[basisKey] === head.patches[headKey]) {
|
|
37
|
+
delta.unchanged.push(path)
|
|
38
|
+
} else {
|
|
39
|
+
delta.changed.push(path)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const path of basisKeys.keys()) {
|
|
43
|
+
if (!headKeys.has(path)) {
|
|
44
|
+
delta.removed.push(path)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const list of Object.values(delta)) {
|
|
48
|
+
list.sort()
|
|
49
|
+
}
|
|
50
|
+
return delta
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The basis canvas divided in two. A layer is carried whole when the head touches none of its
|
|
55
|
+
* files; otherwise the layer is re-judged, and only the files the head leaves alone keep their
|
|
56
|
+
* note, folds, and annotations. A point is carried when the file it sits in is untouched, which
|
|
57
|
+
* keeps its title and so its fingerprint, and with it any dismissal the reviewer made.
|
|
58
|
+
*/
|
|
59
|
+
export function splitBasis(
|
|
60
|
+
artifact: ReviewArtifact,
|
|
61
|
+
delta: FileDelta
|
|
62
|
+
): Omit<BasisSplit, 'canvasSha' | 'reviewJsonPath' | 'files'> {
|
|
63
|
+
const unchanged = new Set(delta.unchanged)
|
|
64
|
+
const layers: BasisSplitLayer[] = artifact.layers.map(layer => {
|
|
65
|
+
const carriedFiles = layer.files.filter(f => unchanged.has(f.path)).map(f => f.path)
|
|
66
|
+
const reJudgedFiles = layer.files.filter(f => !unchanged.has(f.path)).map(f => f.path)
|
|
67
|
+
return {
|
|
68
|
+
key: layer.key,
|
|
69
|
+
title: layer.title,
|
|
70
|
+
status: reJudgedFiles.length === 0 ? 'carried' : 're-judged',
|
|
71
|
+
carriedFiles,
|
|
72
|
+
reJudgedFiles,
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
const points: BasisSplitPoint[] = artifact.points.map(point => ({
|
|
76
|
+
kind: point.kind,
|
|
77
|
+
path: point.path,
|
|
78
|
+
title: point.title,
|
|
79
|
+
status: unchanged.has(point.path) ? 'carried' : 're-judged',
|
|
80
|
+
}))
|
|
81
|
+
return { layers, points }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The canvas an incremental run builds on: the newest one generated for a commit this head was
|
|
86
|
+
* built on. A canvas of a line of work the head no longer contains describes code that was
|
|
87
|
+
* abandoned, so it is never a basis, however recent it is.
|
|
88
|
+
*/
|
|
89
|
+
export async function findBasisCanvas(
|
|
90
|
+
canvases: CanvasStore,
|
|
91
|
+
git: Git,
|
|
92
|
+
prNumber: number | undefined,
|
|
93
|
+
headSha: string
|
|
94
|
+
): Promise<string | null> {
|
|
95
|
+
const index = await canvases.readIndex()
|
|
96
|
+
const candidates = Object.entries(index.canvases)
|
|
97
|
+
.filter(
|
|
98
|
+
([sha, entry]) => sha !== headSha && (entry.prNumber === undefined || entry.prNumber === prNumber)
|
|
99
|
+
)
|
|
100
|
+
.sort(([, a], [, b]) => (a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0))
|
|
101
|
+
for (const [sha] of candidates) {
|
|
102
|
+
if (await git.isAncestor(sha, headSha)) {
|
|
103
|
+
return sha
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
package/src/review/normalize.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface NormalizeInput {
|
|
|
29
29
|
caps: TextCaps
|
|
30
30
|
generatedAt: string
|
|
31
31
|
generator: Generator
|
|
32
|
+
/** The canvas this one was generated from, when the run was incremental. */
|
|
33
|
+
basisCanvasSha?: string | undefined
|
|
32
34
|
/** The globs that make a file a test; the project config's list, or the built-in one. */
|
|
33
35
|
testPatterns?: readonly string[] | undefined
|
|
34
36
|
}
|
|
@@ -76,16 +78,20 @@ function unionRisk(layers: readonly Layer[]): RiskTag[] {
|
|
|
76
78
|
return out
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* A layer's id is its own key, which the validator has already checked is unique here. A reviewed
|
|
83
|
+
* mark is keyed by it, so a regenerated canvas that reorders or renames its layers keeps the
|
|
84
|
+
* reviewer's progress pointing at the same concern; a position could not.
|
|
85
|
+
*/
|
|
79
86
|
function toLayer(
|
|
80
87
|
layer: ModelLayer,
|
|
81
|
-
index: number,
|
|
82
88
|
highRisk: readonly HighRiskRule[],
|
|
83
89
|
testPatterns: readonly string[]
|
|
84
90
|
): Layer {
|
|
85
91
|
const { risk: _modelRisk, files, ...rest } = layer
|
|
86
92
|
return {
|
|
87
93
|
...rest,
|
|
88
|
-
id:
|
|
94
|
+
id: layer.key,
|
|
89
95
|
risk: layerRisk(layer, highRisk),
|
|
90
96
|
files: files.map(f => ({ ...f, isTest: isTestPath(f.path, testPatterns) })),
|
|
91
97
|
}
|
|
@@ -167,12 +173,12 @@ function sortPoints(points: Unassigned[]): Point[] {
|
|
|
167
173
|
|
|
168
174
|
export function normalize(output: ModelOutput, input: NormalizeInput): ReviewArtifact {
|
|
169
175
|
const testPatterns = input.testPatterns ?? DEFAULT_TEST_PATTERNS
|
|
170
|
-
const layers = output.layers.map(
|
|
176
|
+
const layers = output.layers.map(l => toLayer(l, input.highRisk, testPatterns))
|
|
171
177
|
const points = [
|
|
172
178
|
...output.points.map(p => modelPoint(p, layers, input.files)),
|
|
173
179
|
...layers.flatMap(l => testPoints(l, input.files, input.caps.pointTitle)),
|
|
174
180
|
]
|
|
175
|
-
|
|
181
|
+
const artifact: ReviewArtifact = {
|
|
176
182
|
version: 1,
|
|
177
183
|
pr: input.pr,
|
|
178
184
|
files: [...input.files],
|
|
@@ -184,6 +190,10 @@ export function normalize(output: ModelOutput, input: NormalizeInput): ReviewArt
|
|
|
184
190
|
generator: input.generator,
|
|
185
191
|
source: 'local',
|
|
186
192
|
}
|
|
193
|
+
if (input.basisCanvasSha !== undefined) {
|
|
194
|
+
artifact.basisCanvasSha = input.basisCanvasSha
|
|
195
|
+
}
|
|
196
|
+
return artifact
|
|
187
197
|
}
|
|
188
198
|
|
|
189
199
|
/**
|
package/src/review/prepare.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { appendFile, readdir, rm } from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import {
|
|
6
|
+
type BasisSplit,
|
|
6
7
|
type GenerationContext,
|
|
7
8
|
isLargePr,
|
|
8
9
|
type PrepareTarget,
|
|
@@ -16,6 +17,8 @@ import { fetchPrRefs } from '../git/pr-refs.js'
|
|
|
16
17
|
import { toPr } from '../host/pr.js'
|
|
17
18
|
import type { AppContext } from '../server/context.js'
|
|
18
19
|
import { readText, writeJsonAtomic, writeTextAtomic } from '../store/atomic-json.js'
|
|
20
|
+
import type { Derived } from '../store/derived-store.js'
|
|
21
|
+
import { fileDelta, findBasisCanvas, splitBasis } from './incremental.js'
|
|
19
22
|
import { loadPromptSources, type PromptSources, renderPrompt } from './prompt.js'
|
|
20
23
|
|
|
21
24
|
export interface PrepareOptions {
|
|
@@ -131,6 +134,45 @@ async function clearCanvasDir(canvasDir: string): Promise<void> {
|
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The basis canvas of this run, split into what carries and what is re-judged, or null when the
|
|
139
|
+
* canvas is generated from a blank page: `--force`, `canvas.incremental: false`, no canvas of an
|
|
140
|
+
* ancestor commit, or a basis whose canvas or diff this machine can no longer read.
|
|
141
|
+
*
|
|
142
|
+
* Only a pull request's canvases are owned by a number; a refs or local run considers the canvases
|
|
143
|
+
* that carry no number, the same ownership rule the store's own lookup uses.
|
|
144
|
+
*/
|
|
145
|
+
async function resolveBasis(
|
|
146
|
+
ctx: AppContext,
|
|
147
|
+
target: PrepareTarget,
|
|
148
|
+
pr: Pr,
|
|
149
|
+
head: Derived
|
|
150
|
+
): Promise<BasisSplit | null> {
|
|
151
|
+
const prNumber = target.kind === 'pr' ? target.number : undefined
|
|
152
|
+
const sha = await findBasisCanvas(ctx.canvases, ctx.git, prNumber, pr.headSha)
|
|
153
|
+
if (sha === null) {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
const [artifact, manifest] = await Promise.all([
|
|
157
|
+
ctx.canvases.readArtifact(sha),
|
|
158
|
+
ctx.canvases.readManifest(sha),
|
|
159
|
+
])
|
|
160
|
+
if (artifact === null || manifest === null) {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
const basis = await ctx.derived.readOrBuild(sha, manifest.mergeBaseSha)
|
|
164
|
+
if (basis === null) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
const files = fileDelta(basis, head)
|
|
168
|
+
return {
|
|
169
|
+
canvasSha: sha,
|
|
170
|
+
reviewJsonPath: path.join(ctx.canvases.canvasDir(sha), 'review.json'),
|
|
171
|
+
files,
|
|
172
|
+
...splitBasis(artifact, files),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
134
176
|
/** The target with its base resolved, which is the form `context.json` records. */
|
|
135
177
|
async function resolveTarget(ctx: AppContext, input: PrepareTargetInput): Promise<PrepareTarget> {
|
|
136
178
|
if (input.kind !== 'local') {
|
|
@@ -216,6 +258,11 @@ export async function prepare(
|
|
|
216
258
|
largePr: isLargePr({ files: derived.files.length, additions, deletions }),
|
|
217
259
|
preparedAt: ctx.now().toISOString(),
|
|
218
260
|
}
|
|
261
|
+
// `--force` means start over, so it never reads a basis, whatever the project config says.
|
|
262
|
+
const basis = opts.force || !config.canvas.incremental ? null : await resolveBasis(ctx, target, pr, derived)
|
|
263
|
+
if (basis !== null) {
|
|
264
|
+
context.basis = basis
|
|
265
|
+
}
|
|
219
266
|
const sources =
|
|
220
267
|
opts.promptSources ??
|
|
221
268
|
(await loadPromptSources(undefined, {
|
package/src/review/prompt.ts
CHANGED
|
@@ -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 [
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/review/publish.ts
CHANGED
|
@@ -232,6 +232,7 @@ export async function publish(
|
|
|
232
232
|
generatedAt: now,
|
|
233
233
|
generator,
|
|
234
234
|
testPatterns: context.tests.patterns,
|
|
235
|
+
basisCanvasSha: context.basis?.canvasSha,
|
|
235
236
|
})
|
|
236
237
|
const manifest = buildManifest(context, artifact, ctx.version)
|
|
237
238
|
// A snapshot commit is on no branch, so it must never be offered as a pull request's canvas.
|