@vintasoftware/pr-review-canvas 0.1.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.
- package/README.md +30 -28
- package/docs/reference.md +142 -98
- package/package.json +12 -3
- package/pr-review.config.example.yml +12 -31
- package/prompts/generation-format.md +4 -4
- package/prompts/layering-guidance.md +18 -0
- package/skills/pr-review-canvas/SKILL.md +8 -1
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/export.ts +11 -2
- package/src/canvas/name.ts +13 -13
- package/src/canvas/zip.ts +4 -1
- package/src/chat/chat-manager.ts +16 -3
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/cli.ts +16 -13
- package/src/commands.ts +41 -11
- package/src/config.ts +15 -3
- package/src/contract/comments.ts +3 -1
- package/src/contract/generation-context.ts +3 -1
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/state.ts +3 -1
- package/src/git/diff-collector.ts +2 -1
- package/src/git/git.ts +9 -4
- package/src/github/attachments.ts +3 -3
- package/src/github/capabilities.ts +5 -1
- package/src/github/comments.ts +6 -3
- package/src/github/gh.ts +18 -3
- package/src/github/post-comment.ts +4 -1
- package/src/github/pr.ts +4 -1
- package/src/github/threads.ts +5 -1
- package/src/project-config.ts +14 -54
- package/src/prompt-files.ts +4 -3
- package/src/review/doctor.ts +38 -10
- package/src/review/install-skill.ts +24 -28
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +24 -8
- package/src/review/prompt.ts +17 -11
- package/src/review/publish.ts +15 -3
- package/src/review/skill-content.ts +20 -0
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate.ts +65 -13
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +17 -4
- package/src/server/context.ts +14 -2
- package/src/server/errors.ts +26 -2
- package/src/server/html.ts +2 -2
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +29 -5
- package/src/server/routes/chat-routes.ts +30 -9
- package/src/server/routes/pages.ts +3 -1
- package/src/server/routes/review-routes.ts +19 -4
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +20 -6
- package/src/store/derived-store.ts +5 -1
- package/src/store/pr-store.ts +2 -1
- package/src/store/state-store.ts +2 -1
- package/static/brand.svg +19 -0
- package/static/js/api.js +15 -3
- package/static/js/app.js +8 -2
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +38 -15
- package/static/js/composer.js +6 -3
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +6 -1
- package/static/js/dom.js +5 -7
- package/static/js/empty-state.js +3 -1
- package/static/js/header.js +1 -1
- package/static/js/interactions.js +55 -15
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +23 -11
- package/static/js/links.js +8 -2
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +3 -2
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/settings.js +1 -1
- package/static/js/signoff.js +6 -2
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +4 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/responsive.css +1 -15
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +93 -100
- package/static/styles.css +13 -12
- package/prompts/layers-default.md +0 -13
package/src/github/comments.ts
CHANGED
|
@@ -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 })
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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(
|
|
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)}`,
|
package/src/github/threads.ts
CHANGED
|
@@ -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(
|
|
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 (;;) {
|
package/src/project-config.ts
CHANGED
|
@@ -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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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({
|
|
@@ -86,49 +88,9 @@ const PartialProjectConfigSchema = z.object({
|
|
|
86
88
|
chat: z.object({ enabled: z.boolean().optional() }).optional(),
|
|
87
89
|
})
|
|
88
90
|
|
|
89
|
-
/** The 8 architecture groups, in review order. A project file replaces the list. */
|
|
90
|
-
export const DEFAULT_LAYERS: DefaultLayer[] = [
|
|
91
|
-
{
|
|
92
|
-
id: 'contracts',
|
|
93
|
-
title: 'Contracts and schemas',
|
|
94
|
-
description: 'Types, Zod schemas, FHIR profiles, and the shapes two packages must agree on.',
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
id: 'data-access',
|
|
98
|
-
title: 'Data access',
|
|
99
|
-
description: 'Server functions, FHIR client calls, repositories, migrations, and storage.',
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
id: 'mappers',
|
|
103
|
-
title: 'Mappers and DTOs',
|
|
104
|
-
description: 'Code that turns resources or rows into view models and back.',
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
id: 'hooks-state',
|
|
108
|
-
title: 'Hooks and state',
|
|
109
|
-
description: 'React hooks, query definitions, stores, and client-side state machines.',
|
|
110
|
-
},
|
|
111
|
-
{ id: 'views', title: 'Views', description: 'Components, screens, styles, and copy.' },
|
|
112
|
-
{
|
|
113
|
-
id: 'routes-wiring',
|
|
114
|
-
title: 'Routes and wiring',
|
|
115
|
-
description: 'Route files, providers, app shells, dependency wiring, and entry points.',
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
id: 'policy-config',
|
|
119
|
-
title: 'Policy and config',
|
|
120
|
-
description: 'Access policies, environment variables, feature flags, CI, and deployment config.',
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
id: 'mechanical',
|
|
124
|
-
title: 'Mechanical changes',
|
|
125
|
-
description: 'Renames, lockfiles, generated files, formatting, and moved code.',
|
|
126
|
-
},
|
|
127
|
-
]
|
|
128
|
-
|
|
129
91
|
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
|
130
92
|
version: 1,
|
|
131
|
-
layers:
|
|
93
|
+
layers: [],
|
|
132
94
|
highRisk: [],
|
|
133
95
|
generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
|
|
134
96
|
tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
|
|
@@ -159,7 +121,8 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
|
|
|
159
121
|
const generation: ProjectConfig['generation'] = {
|
|
160
122
|
mode: user.generation?.mode ?? DEFAULT_PROJECT_CONFIG.generation.mode,
|
|
161
123
|
maxRepairRounds: user.generation?.maxRepairRounds ?? DEFAULT_PROJECT_CONFIG.generation.maxRepairRounds,
|
|
162
|
-
inlineDiffMaxLines:
|
|
124
|
+
inlineDiffMaxLines:
|
|
125
|
+
user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
|
|
163
126
|
smallPrHunks: user.generation?.smallPrHunks ?? DEFAULT_PROJECT_CONFIG.generation.smallPrHunks,
|
|
164
127
|
}
|
|
165
128
|
if (user.generation?.caps !== undefined) {
|
|
@@ -167,7 +130,7 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
|
|
|
167
130
|
}
|
|
168
131
|
const config: ProjectConfig = {
|
|
169
132
|
version: 1,
|
|
170
|
-
layers: user.layers ??
|
|
133
|
+
layers: user.layers ?? [],
|
|
171
134
|
highRisk: user.highRisk ?? [],
|
|
172
135
|
generation,
|
|
173
136
|
tests: { patterns: user.tests?.patterns ?? [...DEFAULT_TEST_PATTERNS] },
|
|
@@ -180,9 +143,6 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
|
|
|
180
143
|
config.prompts = user.prompts
|
|
181
144
|
}
|
|
182
145
|
const warnings: string[] = []
|
|
183
|
-
if (config.layers.length === 0) {
|
|
184
|
-
warnings.push(`${PROJECT_CONFIG_FILE}: "layers" is empty, the model gets no default taxonomy`)
|
|
185
|
-
}
|
|
186
146
|
if (config.tests.patterns.length === 0) {
|
|
187
147
|
warnings.push(`${PROJECT_CONFIG_FILE}: "tests.patterns" is empty, so no file counts as a test`)
|
|
188
148
|
}
|
package/src/prompt-files.ts
CHANGED
|
@@ -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 =
|
|
19
|
-
|
|
20
|
-
|
|
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) {
|
package/src/review/doctor.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// `pr-review doctor`: one pass over everything the tool needs before it can serve a review, as
|
|
2
2
|
// one JSON line. It reports instead of throwing, so a broken setup still answers.
|
|
3
3
|
import { randomBytes } from 'node:crypto'
|
|
4
|
-
import { rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { readFile, rm, writeFile } from 'node:fs/promises'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { parseGithubRemote } from '../config.js'
|
|
7
7
|
import type { Git } from '../git/git.js'
|
|
8
8
|
import type { GitHubClient } from '../github/gh.js'
|
|
9
9
|
import { ensureDataDir, resolveDataDir } from '../store/data-dir.js'
|
|
10
|
-
import { CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILL_NAME } from './install-skill.js'
|
|
10
|
+
import { CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILL_NAME, SKILL_SOURCE_DIR } from './install-skill.js'
|
|
11
|
+
import { skillContent } from './skill-content.js'
|
|
11
12
|
|
|
12
13
|
export const DOCTOR_CHECKS = ['git', 'origin', 'gh', 'ghAuth', 'dataDir', 'skill'] as const
|
|
13
14
|
export type DoctorCheckName = (typeof DOCTOR_CHECKS)[number]
|
|
@@ -31,8 +32,7 @@ export interface DoctorDeps {
|
|
|
31
32
|
acpxVersion: () => Promise<string | null>
|
|
32
33
|
/** `--data-dir` or `PR_REVIEW_DATA_DIR`; without it the dir sits next to the git common dir. */
|
|
33
34
|
dataDirOverride?: string | undefined
|
|
34
|
-
|
|
35
|
-
exists: (file: string) => Promise<boolean>
|
|
35
|
+
readSkill?: (file: string) => Promise<string | null>
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function message(err: unknown): string {
|
|
@@ -56,17 +56,42 @@ async function checkDataDir(dir: string): Promise<DoctorCheck> {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** The skill the generation flow needs, in either harness's directory. */
|
|
59
|
-
async function checkSkill(
|
|
59
|
+
export async function checkSkill(
|
|
60
|
+
repoRoot: string | null,
|
|
61
|
+
readSkill: NonNullable<DoctorDeps['readSkill']> = file => readFile(file, 'utf8')
|
|
62
|
+
): Promise<DoctorCheck> {
|
|
60
63
|
if (repoRoot === null) {
|
|
61
64
|
return { ok: false, detail: 'no repository, so no skill directory to look in', hint: 'run from a clone' }
|
|
62
65
|
}
|
|
63
66
|
const targets = [CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR].map(dir => path.join(repoRoot, dir, SKILL_NAME))
|
|
64
67
|
const found: string[] = []
|
|
68
|
+
const stale: string[] = []
|
|
69
|
+
let expected: string
|
|
70
|
+
try {
|
|
71
|
+
expected = skillContent(await readFile(path.join(SKILL_SOURCE_DIR, 'SKILL.md'), 'utf8')).hash
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return { ok: false, detail: message(err), hint: 'reinstall the pr-review package' }
|
|
74
|
+
}
|
|
65
75
|
for (const target of targets) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
try {
|
|
77
|
+
const text = await readSkill(path.join(target, 'SKILL.md'))
|
|
78
|
+
if (text === null) continue
|
|
69
79
|
found.push(path.relative(repoRoot, target))
|
|
80
|
+
const { hash, frontmatter } = skillContent(text)
|
|
81
|
+
if (hash !== expected || frontmatter.getIn(['metadata', 'body-sha256']) !== expected) {
|
|
82
|
+
stale.push(path.relative(repoRoot, target))
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
86
|
+
stale.push(`${path.relative(repoRoot, target)}: ${message(err)}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (stale.length > 0) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
detail: `outdated or modified skill: ${stale.join(', ')}`,
|
|
94
|
+
hint: 'run `pr-review install-skill`',
|
|
70
95
|
}
|
|
71
96
|
}
|
|
72
97
|
if (found.length === 0) {
|
|
@@ -92,7 +117,10 @@ async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
|
|
|
92
117
|
}
|
|
93
118
|
|
|
94
119
|
/** Runs core checks and, with allChecks, checks acpx for AI Chat. */
|
|
95
|
-
export async function runDoctorChecks(
|
|
120
|
+
export async function runDoctorChecks(
|
|
121
|
+
deps: DoctorDeps,
|
|
122
|
+
options: { allChecks?: boolean } = {}
|
|
123
|
+
): Promise<DoctorReport> {
|
|
96
124
|
let repoRoot: string | null = null
|
|
97
125
|
let git: DoctorCheck
|
|
98
126
|
try {
|
|
@@ -130,7 +158,7 @@ export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: b
|
|
|
130
158
|
dataDir = { ok: false, detail: message(err), hint: 'pass --data-dir <dir>' }
|
|
131
159
|
}
|
|
132
160
|
|
|
133
|
-
const skill = await checkSkill(repoRoot, deps.
|
|
161
|
+
const skill = await checkSkill(repoRoot, deps.readSkill)
|
|
134
162
|
const checks: DoctorReport['checks'] = { git, origin, gh, ghAuth, dataDir, skill }
|
|
135
163
|
if (options.allChecks) {
|
|
136
164
|
checks.acpx = await checkAcpx(deps)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// `pr-review install-skill`:
|
|
1
|
+
// `pr-review install-skill`: copy the bundled skill into the host repo's skill directories, so
|
|
2
2
|
// Claude Code (`.claude/skills`) and Codex (`.agents/skills`) both see `/pr-review-canvas`.
|
|
3
|
-
import { appendFile, cp, lstat, mkdir,
|
|
3
|
+
import { appendFile, cp, lstat, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { PACKAGE_ROOT } from '../server/context.js'
|
|
6
6
|
import { readText } from '../store/atomic-json.js'
|
|
7
|
+
import { stampSkill } from './skill-content.js'
|
|
7
8
|
|
|
8
9
|
export const SKILL_NAME = 'pr-review-canvas'
|
|
9
10
|
export const SKILL_SOURCE_DIR = path.join(PACKAGE_ROOT, 'skills', SKILL_NAME)
|
|
@@ -27,39 +28,38 @@ export interface InstallSkillOptions {
|
|
|
27
28
|
/** Absolute skills directories to install into; each gets `<dir>/pr-review-canvas`. */
|
|
28
29
|
targets: Array<{ kind: 'claude' | 'codex'; dir: string }>
|
|
29
30
|
source?: string
|
|
30
|
-
/** Windows copies; everything else links. */
|
|
31
|
-
platform?: NodeJS.Platform
|
|
32
31
|
/** Replace a real directory that already sits at the target. */
|
|
33
32
|
force?: boolean
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
/**
|
|
35
|
+
/** Preserve directories that were not created by the installer unless forced. */
|
|
37
36
|
export class SkillDirExistsError extends Error {
|
|
38
37
|
readonly path: string
|
|
39
38
|
|
|
40
39
|
constructor(target: string) {
|
|
41
|
-
super(`${target} is a directory, not a
|
|
40
|
+
super(`${target} is a directory, not a managed copy of the bundled skill`)
|
|
42
41
|
this.name = 'SkillDirExistsError'
|
|
43
42
|
this.path = target
|
|
44
43
|
}
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
export type InstallStatus = '
|
|
46
|
+
export type InstallStatus = 'copied'
|
|
48
47
|
|
|
49
48
|
export interface InstallSkillResult {
|
|
50
49
|
skill: string
|
|
51
50
|
targets: Array<{ kind: 'claude' | 'codex'; path: string; status: InstallStatus }>
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
/**
|
|
55
|
-
async function inspect(target: string): Promise<{ kind: 'none' } | { kind: 'link'
|
|
53
|
+
/** Inspect the entry without following a possibly dangling symlink. */
|
|
54
|
+
async function inspect(target: string): Promise<{ kind: 'none' } | { kind: 'link' } | { kind: 'other' }> {
|
|
56
55
|
let stats: Awaited<ReturnType<typeof lstat>>
|
|
57
56
|
try {
|
|
58
57
|
stats = await lstat(target)
|
|
59
|
-
} catch {
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
60
60
|
return { kind: 'none' }
|
|
61
61
|
}
|
|
62
|
-
return stats.isSymbolicLink() ? { kind: 'link'
|
|
62
|
+
return stats.isSymbolicLink() ? { kind: 'link' } : { kind: 'other' }
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** A copy carries a marker file, so a later run can tell it from a hand-made directory. */
|
|
@@ -68,7 +68,7 @@ export const COPY_MARKER = '.pr-review-install'
|
|
|
68
68
|
async function installOne(
|
|
69
69
|
source: string,
|
|
70
70
|
dir: string,
|
|
71
|
-
|
|
71
|
+
content: string,
|
|
72
72
|
force: boolean
|
|
73
73
|
): Promise<{ path: string; status: InstallStatus }> {
|
|
74
74
|
await mkdir(dir, { recursive: true })
|
|
@@ -78,29 +78,25 @@ async function installOne(
|
|
|
78
78
|
if (current.kind === 'other' && !force && (await inspect(path.join(target, COPY_MARKER))).kind === 'none') {
|
|
79
79
|
throw new SkillDirExistsError(target)
|
|
80
80
|
}
|
|
81
|
-
if (
|
|
82
|
-
await
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
if (current.kind === 'other') {
|
|
82
|
+
const realTarget = await realpath(target)
|
|
83
|
+
if (source === realTarget || source.startsWith(`${realTarget}${path.sep}`)) {
|
|
84
|
+
throw new Error('Cannot install a skill over its source directory')
|
|
85
|
+
}
|
|
86
86
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
await rm(target, { recursive: true, force: true })
|
|
93
|
-
}
|
|
94
|
-
await symlink(relative, target, 'dir')
|
|
95
|
-
return { path: target, status: current.kind === 'none' ? 'linked' : 'replaced' }
|
|
87
|
+
await rm(target, { recursive: true, force: true })
|
|
88
|
+
await cp(source, target, { recursive: true, dereference: true })
|
|
89
|
+
await writeFile(path.join(target, 'SKILL.md'), content, 'utf8')
|
|
90
|
+
await writeFile(path.join(target, COPY_MARKER), 'pr-review managed skill copy\n', 'utf8')
|
|
91
|
+
return { path: target, status: 'copied' }
|
|
96
92
|
}
|
|
97
93
|
|
|
98
94
|
export async function installSkill(opts: InstallSkillOptions): Promise<InstallSkillResult> {
|
|
99
95
|
const source = await realpath(opts.source ?? SKILL_SOURCE_DIR)
|
|
100
|
-
const
|
|
96
|
+
const content = stampSkill(await readFile(path.join(source, 'SKILL.md'), 'utf8'))
|
|
101
97
|
const targets: InstallSkillResult['targets'] = []
|
|
102
98
|
for (const t of opts.targets) {
|
|
103
|
-
const done = await installOne(source, t.dir,
|
|
99
|
+
const done = await installOne(source, t.dir, content, opts.force === true)
|
|
104
100
|
targets.push({ kind: t.kind, ...done })
|
|
105
101
|
}
|
|
106
102
|
return { skill: SKILL_NAME, targets }
|
package/src/review/normalize.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/review/prepare.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
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
|
}
|
package/src/review/prompt.ts
CHANGED
|
@@ -13,19 +13,19 @@ export { PROMPTS_DIR }
|
|
|
13
13
|
export interface PromptSources {
|
|
14
14
|
generation: Record<GenerationMode, string>
|
|
15
15
|
format: string
|
|
16
|
-
|
|
16
|
+
layeringGuidance: string
|
|
17
17
|
qualityStandards: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export async function loadPromptSources(dir = PROMPTS_DIR, project?: ProjectPrompts): Promise<PromptSources> {
|
|
21
|
-
const [format,
|
|
21
|
+
const [format, layeringGuidance, qualityStandards, strict, surfacing] = await Promise.all([
|
|
22
22
|
loadPromptFile('generation-format.md', dir, project),
|
|
23
|
-
loadPromptFile('
|
|
23
|
+
loadPromptFile('layering-guidance.md', dir, project),
|
|
24
24
|
loadPromptFile('quality-standards.md', dir, project),
|
|
25
25
|
loadPromptFile('generation-strict.md', dir, project),
|
|
26
26
|
loadPromptFile('generation-surfacing.md', dir, project),
|
|
27
27
|
])
|
|
28
|
-
return { format,
|
|
28
|
+
return { format, layeringGuidance, qualityStandards, generation: { strict, surfacing } }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** The line ranges of a hunk header; the trailing function context can hold backticks. */
|
|
@@ -64,14 +64,16 @@ function inlineDiffs(files: readonly FileEntry[], patches: Record<string, string
|
|
|
64
64
|
.join('\n\n')
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function
|
|
67
|
+
function configuredLayersMarkdown(ctx: GenerationContext): string {
|
|
68
68
|
if (ctx.defaultLayers.length === 0) {
|
|
69
|
-
return '
|
|
69
|
+
return '_No layers are configured; divide the change into semantic sections based on its behavior and concerns._'
|
|
70
70
|
}
|
|
71
71
|
return ctx.defaultLayers
|
|
72
72
|
.map((l, i) => {
|
|
73
73
|
const paths =
|
|
74
|
-
l.paths === undefined || l.paths.length === 0
|
|
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')
|
|
@@ -164,7 +166,7 @@ function smallPrMarkdown(ctx: GenerationContext): string {
|
|
|
164
166
|
}
|
|
165
167
|
return (
|
|
166
168
|
`**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
|
|
169
|
+
'- Use one layer unless the concerns truly differ; do not split merely to fill suggested groups.\n' +
|
|
168
170
|
'- Annotate only where the diff does not speak for itself; zero annotations is a fine answer.\n' +
|
|
169
171
|
'- Keep the summary self-contained: state the behavior change and the one relationship or decision worth understanding.'
|
|
170
172
|
)
|
|
@@ -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(
|
|
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),
|
|
@@ -207,8 +213,8 @@ export function renderPrompt(ctx: GenerationContext, patches: Record<string, str
|
|
|
207
213
|
MODEL_PATH: ctx.paths.model,
|
|
208
214
|
MANIFEST: manifestMarkdown(ctx.files),
|
|
209
215
|
DIFFS: diffsMarkdown(ctx, patches),
|
|
210
|
-
|
|
211
|
-
|
|
216
|
+
CONFIGURED_LAYERS: configuredLayersMarkdown(ctx),
|
|
217
|
+
LAYERING_GUIDANCE: sources.layeringGuidance.trim(),
|
|
212
218
|
CAPS: capsMarkdown(ctx),
|
|
213
219
|
MAX_POINTS: String(ctx.limits.maxPoints),
|
|
214
220
|
MAX_DIAGRAMS: String(ctx.limits.maxDiagramsPerLayer),
|
package/src/review/publish.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { parseDocument } from 'yaml'
|
|
3
|
+
|
|
4
|
+
/** Normalize checkout line endings so Git's CRLF conversion does not stale a copy. */
|
|
5
|
+
export function skillContent(text: string) {
|
|
6
|
+
const normalized = text.replace(/\r\n/g, '\n')
|
|
7
|
+
const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(normalized)
|
|
8
|
+
if (!match) throw new Error('SKILL.md is missing YAML frontmatter')
|
|
9
|
+
const frontmatter = parseDocument(match[1]!)
|
|
10
|
+
if (frontmatter.errors.length) throw new Error('SKILL.md has invalid YAML frontmatter')
|
|
11
|
+
const body = normalized.slice(match[0].length)
|
|
12
|
+
const hash = createHash('sha256').update(body).digest('hex')
|
|
13
|
+
return { frontmatter, body, hash }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stampSkill(text: string): string {
|
|
17
|
+
const { frontmatter, body, hash } = skillContent(text)
|
|
18
|
+
frontmatter.setIn(['metadata', 'body-sha256'], hash)
|
|
19
|
+
return `---\n${frontmatter.toString()}---\n${body}`
|
|
20
|
+
}
|