@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
package/src/github/pr.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Pr, Repo } from '../contract/review-artifact.js'
|
|
3
|
+
import type { Git } from '../git/git.js'
|
|
4
|
+
import { GitHubApiError, type GitHubClient } from './gh.js'
|
|
5
|
+
|
|
6
|
+
/** The subset of GitHub's pull request object the tool reads. */
|
|
7
|
+
const GhPullSchema = z.object({
|
|
8
|
+
number: z.number().int(),
|
|
9
|
+
title: z.string(),
|
|
10
|
+
body: z.string().nullable(),
|
|
11
|
+
html_url: z.string(),
|
|
12
|
+
state: z.string(),
|
|
13
|
+
draft: z.boolean().optional(),
|
|
14
|
+
merged: z.boolean().optional(),
|
|
15
|
+
merge_commit_sha: z.string().nullable().optional(),
|
|
16
|
+
updated_at: z.string(),
|
|
17
|
+
additions: z.number().int(),
|
|
18
|
+
deletions: z.number().int(),
|
|
19
|
+
changed_files: z.number().int(),
|
|
20
|
+
user: z.object({ login: z.string() }).nullable(),
|
|
21
|
+
base: z.object({ ref: z.string() }),
|
|
22
|
+
head: z.object({ ref: z.string(), sha: z.string() }),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export class PrNotFoundError extends Error {
|
|
26
|
+
readonly number: number
|
|
27
|
+
|
|
28
|
+
constructor(number: number) {
|
|
29
|
+
super(`pull request #${number} not found`)
|
|
30
|
+
this.name = 'PrNotFoundError'
|
|
31
|
+
this.number = number
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PrMeta {
|
|
36
|
+
number: number
|
|
37
|
+
title: string
|
|
38
|
+
body: string
|
|
39
|
+
author: string
|
|
40
|
+
url: string
|
|
41
|
+
state: string
|
|
42
|
+
draft: boolean
|
|
43
|
+
updatedAt: string
|
|
44
|
+
baseRef: string
|
|
45
|
+
headRef: string
|
|
46
|
+
headSha: string
|
|
47
|
+
/** The commit that merged the PR into its base, once merged. */
|
|
48
|
+
mergeCommitSha: string | null
|
|
49
|
+
additions: number
|
|
50
|
+
deletions: number
|
|
51
|
+
changedFiles: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mapPull(raw: unknown): PrMeta {
|
|
55
|
+
const p = GhPullSchema.parse(raw)
|
|
56
|
+
return {
|
|
57
|
+
number: p.number,
|
|
58
|
+
title: p.title,
|
|
59
|
+
body: p.body ?? '',
|
|
60
|
+
author: p.user?.login ?? 'ghost',
|
|
61
|
+
url: p.html_url,
|
|
62
|
+
state: p.merged === true ? 'merged' : p.state,
|
|
63
|
+
draft: p.draft === true,
|
|
64
|
+
updatedAt: p.updated_at,
|
|
65
|
+
baseRef: p.base.ref,
|
|
66
|
+
headRef: p.head.ref,
|
|
67
|
+
headSha: p.head.sha,
|
|
68
|
+
mergeCommitSha: p.merged === true ? (p.merge_commit_sha ?? null) : null,
|
|
69
|
+
additions: p.additions,
|
|
70
|
+
deletions: p.deletions,
|
|
71
|
+
changedFiles: p.changed_files,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function fetchPrMeta(gh: GitHubClient, repo: Repo, number: number): Promise<PrMeta> {
|
|
76
|
+
try {
|
|
77
|
+
return mapPull(await gh.api(`repos/${repo.owner}/${repo.name}/pulls/${number}`))
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err instanceof GitHubApiError && err.notFound) {
|
|
80
|
+
throw new PrNotFoundError(number)
|
|
81
|
+
}
|
|
82
|
+
throw err
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function prHeadRef(number: number): string {
|
|
87
|
+
return `refs/pr/${number}/head`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function prBaseRef(number: number): string {
|
|
91
|
+
return `refs/pr/${number}/base`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fetches the PR head and base into local refs and resolves the two commits the diff needs.
|
|
96
|
+
* The refs are the same ones the prior-art skill used, so an existing clone keeps working.
|
|
97
|
+
*
|
|
98
|
+
* A merged PR is diffed against the base as it was at merge time: the merge commit's first
|
|
99
|
+
* parent (a merge commit, a squash, or the last rebased commit all sit on the base branch, so
|
|
100
|
+
* it is local once the base was fetched). Today's base tip would contain the PR and give an
|
|
101
|
+
* empty diff.
|
|
102
|
+
*/
|
|
103
|
+
export async function fetchPrRefs(git: Git, meta: PrMeta): Promise<{ headSha: string; mergeBaseSha: string }> {
|
|
104
|
+
await git.fetch('origin', [
|
|
105
|
+
`+pull/${meta.number}/head:${prHeadRef(meta.number)}`,
|
|
106
|
+
`+refs/heads/${meta.baseRef}:${prBaseRef(meta.number)}`,
|
|
107
|
+
])
|
|
108
|
+
const headSha = await git.revParse(prHeadRef(meta.number))
|
|
109
|
+
const base = meta.mergeCommitSha === null ? prBaseRef(meta.number) : `${meta.mergeCommitSha}^1`
|
|
110
|
+
const mergeBaseSha = await git.mergeBase(base, headSha)
|
|
111
|
+
return { headSha, mergeBaseSha }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function toPr(meta: PrMeta, repo: Repo, shas: { headSha: string; mergeBaseSha: string }): Pr {
|
|
115
|
+
return {
|
|
116
|
+
number: meta.number,
|
|
117
|
+
title: meta.title,
|
|
118
|
+
body: meta.body,
|
|
119
|
+
author: meta.author,
|
|
120
|
+
url: meta.url,
|
|
121
|
+
state: meta.state,
|
|
122
|
+
draft: meta.draft,
|
|
123
|
+
updatedAt: meta.updatedAt,
|
|
124
|
+
baseRef: meta.baseRef,
|
|
125
|
+
headRef: meta.headRef,
|
|
126
|
+
headSha: shas.headSha,
|
|
127
|
+
mergeBaseSha: shas.mergeBaseSha,
|
|
128
|
+
additions: meta.additions,
|
|
129
|
+
deletions: meta.deletions,
|
|
130
|
+
changedFiles: meta.changedFiles,
|
|
131
|
+
repo,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The same reviewed rules the page uses, so the body and the header never disagree.
|
|
2
|
+
import { layerProgress } from '../../static/js/progress.js'
|
|
3
|
+
import type { CommentsPayload } from '../contract/comments.js'
|
|
4
|
+
import type { Layer, ReviewArtifact } from '../contract/review-artifact.js'
|
|
5
|
+
import type { PrState } from '../contract/state.js'
|
|
6
|
+
|
|
7
|
+
export const REVIEW_BODY_FOOTER = 'Reviewed with the pr-review canvas (localhost).'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Model text on one line of a list. Markdown characters are escaped so a title cannot open a
|
|
11
|
+
* heading, a link, or a code span in the review GitHub publishes.
|
|
12
|
+
*/
|
|
13
|
+
export function inlineText(text: string): string {
|
|
14
|
+
return text
|
|
15
|
+
.replace(/\s+/g, ' ')
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/([\\`*_[\]<>#|])/g, '\\$1')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The state as it applies to one commit. Marks made on another commit, and marks whose commit
|
|
22
|
+
* is unknown, describe other code, so they count for nothing here.
|
|
23
|
+
*/
|
|
24
|
+
export function stateForHead(state: PrState, headSha: string): PrState {
|
|
25
|
+
return state.reviewedHeadSha === headSha ? state : { ...state, reviewed: {} }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The layers a human still has to look at. Empty means approve is allowed. */
|
|
29
|
+
export function unreviewedLayers(artifact: ReviewArtifact, state: PrState): Layer[] {
|
|
30
|
+
return artifact.layers.filter(l => l.kind !== 'other' && layerProgress(l, state) !== 'done')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function section(title: string, items: string[]): string {
|
|
34
|
+
return items.length === 0 ? '' : `\n\n**${title}**\n${items.map(i => `- ${i}`).join('\n')}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ReviewBodyInput {
|
|
38
|
+
artifact: ReviewArtifact
|
|
39
|
+
state: PrState
|
|
40
|
+
comments: CommentsPayload
|
|
41
|
+
headSha: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The body of the review the canvas posts: what was read, what was set aside, and what was said
|
|
46
|
+
* on the diff. The user sees it in the dialog and may edit it before it is sent.
|
|
47
|
+
*/
|
|
48
|
+
export function buildReviewBody(input: ReviewBodyInput): string {
|
|
49
|
+
const { artifact, state, comments, headSha } = input
|
|
50
|
+
const layers = artifact.layers.filter(l => l.kind !== 'other')
|
|
51
|
+
const reviewed = layers.filter(l => layerProgress(l, state) === 'done')
|
|
52
|
+
const dismissed = artifact.points.filter(p => state.dismissed[p.fingerprint] !== undefined)
|
|
53
|
+
const urls = new Map<number, string>()
|
|
54
|
+
for (const c of [...comments.reviewComments, ...comments.issueComments]) {
|
|
55
|
+
urls.set(c.id, c.url)
|
|
56
|
+
}
|
|
57
|
+
const posted = state.posted.map(p => urls.get(p.commentId) ?? `comment ${p.commentId}`)
|
|
58
|
+
const head = `Reviewed ${reviewed.length} of ${layers.length} ${layers.length === 1 ? 'layer' : 'layers'} on \`${headSha.slice(0, 7)}\`.`
|
|
59
|
+
return (
|
|
60
|
+
head +
|
|
61
|
+
section(
|
|
62
|
+
'Layers reviewed',
|
|
63
|
+
reviewed.map(l => inlineText(l.title))
|
|
64
|
+
) +
|
|
65
|
+
section(
|
|
66
|
+
'Attention points dismissed',
|
|
67
|
+
dismissed.map(p => inlineText(p.title))
|
|
68
|
+
) +
|
|
69
|
+
section('Comments posted from the canvas', posted) +
|
|
70
|
+
`\n\n${REVIEW_BODY_FOOTER}\n`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
3
|
+
import type { GitHubClient } from './gh.js'
|
|
4
|
+
|
|
5
|
+
const ThreadsPageSchema = z.object({
|
|
6
|
+
repository: z.object({
|
|
7
|
+
pullRequest: z.object({
|
|
8
|
+
reviewThreads: z.object({
|
|
9
|
+
pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }),
|
|
10
|
+
nodes: z.array(
|
|
11
|
+
z.object({
|
|
12
|
+
isResolved: z.boolean(),
|
|
13
|
+
comments: z.object({ nodes: z.array(z.object({ databaseId: z.number().int().nullable() })) }),
|
|
14
|
+
})
|
|
15
|
+
),
|
|
16
|
+
}),
|
|
17
|
+
}),
|
|
18
|
+
}),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const THREADS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) {
|
|
22
|
+
repository(owner: $owner, name: $name) {
|
|
23
|
+
pullRequest(number: $number) {
|
|
24
|
+
reviewThreads(first: 100, after: $after) {
|
|
25
|
+
pageInfo { hasNextPage endCursor }
|
|
26
|
+
nodes { isResolved comments(first: 100) { nodes { databaseId } } }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}`
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The ids of every review comment that sits in a resolved thread. REST has no resolved flag,
|
|
34
|
+
* so this one GraphQL query is joined to the REST comments by id.
|
|
35
|
+
*/
|
|
36
|
+
export async function fetchResolvedCommentIds(gh: GitHubClient, repo: Repo, number: number): Promise<Set<number>> {
|
|
37
|
+
const resolved = new Set<number>()
|
|
38
|
+
let after: string | null = null
|
|
39
|
+
for (;;) {
|
|
40
|
+
const vars: Record<string, string | number> = {
|
|
41
|
+
owner: repo.owner,
|
|
42
|
+
name: repo.name,
|
|
43
|
+
number,
|
|
44
|
+
...(after === null ? {} : { after }),
|
|
45
|
+
}
|
|
46
|
+
const page = ThreadsPageSchema.parse(await gh.graphql(THREADS_QUERY, vars))
|
|
47
|
+
const threads = page.repository.pullRequest.reviewThreads
|
|
48
|
+
for (const t of threads.nodes) {
|
|
49
|
+
if (!t.isResolved) {
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
for (const c of t.comments.nodes) {
|
|
53
|
+
if (c.databaseId !== null) {
|
|
54
|
+
resolved.add(c.databaseId)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!threads.pageInfo.hasNextPage || threads.pageInfo.endCursor === null) {
|
|
59
|
+
return resolved
|
|
60
|
+
}
|
|
61
|
+
after = threads.pageInfo.endCursor
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Where this package's own files live. Its own module so that anything needing a path does not
|
|
2
|
+
// have to import the server context, which pulls in every adapter.
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
export const PACKAGE_ROOT = path.resolve(here, '..')
|
|
9
|
+
export const STATIC_DIR = path.join(PACKAGE_ROOT, 'static')
|
|
10
|
+
export const PROMPTS_DIR = path.join(PACKAGE_ROOT, 'prompts')
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { parse as parseYaml } from 'yaml'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import type { TextCaps } from './contract/review-artifact.js'
|
|
5
|
+
import { DEFAULT_TEST_PATTERNS } from './review/test-paths.js'
|
|
6
|
+
import { readText } from './store/atomic-json.js'
|
|
7
|
+
|
|
8
|
+
export const PROJECT_CONFIG_FILE = 'pr-review.config.yml'
|
|
9
|
+
export const GenerationModeSchema = z.enum(['strict', 'surfacing'])
|
|
10
|
+
export type GenerationMode = z.infer<typeof GenerationModeSchema>
|
|
11
|
+
|
|
12
|
+
export const DefaultLayerSchema = z.object({
|
|
13
|
+
id: z.string().min(1),
|
|
14
|
+
title: z.string().min(1),
|
|
15
|
+
description: z.string(),
|
|
16
|
+
paths: z.array(z.string()).optional(),
|
|
17
|
+
})
|
|
18
|
+
export type DefaultLayer = z.infer<typeof DefaultLayerSchema>
|
|
19
|
+
|
|
20
|
+
export const HighRiskRuleSchema = z.object({ pattern: z.string().min(1), label: z.string().min(1) })
|
|
21
|
+
export type HighRiskRule = z.infer<typeof HighRiskRuleSchema>
|
|
22
|
+
|
|
23
|
+
const cap = () => z.number().int().positive().optional()
|
|
24
|
+
|
|
25
|
+
/** One optional override per text cap. `satisfies` fails the build when TEXT_CAPS gains a key. */
|
|
26
|
+
const capsShape = {
|
|
27
|
+
summary: cap(),
|
|
28
|
+
layerTitle: cap(),
|
|
29
|
+
rationale: cap(),
|
|
30
|
+
decisions: cap(),
|
|
31
|
+
checkByHand: cap(),
|
|
32
|
+
annotation: cap(),
|
|
33
|
+
pointTitle: cap(),
|
|
34
|
+
pointBody: cap(),
|
|
35
|
+
testBehavior: cap(),
|
|
36
|
+
diagram: cap(),
|
|
37
|
+
} satisfies Record<keyof TextCaps, z.ZodOptional<z.ZodNumber>>
|
|
38
|
+
|
|
39
|
+
export const PromptOverridesSchema = z.object({
|
|
40
|
+
'generation-format.md': z.string().min(1).optional(),
|
|
41
|
+
'generation-strict.md': z.string().min(1).optional(),
|
|
42
|
+
'generation-surfacing.md': z.string().min(1).optional(),
|
|
43
|
+
'quality-standards.md': z.string().min(1).optional(),
|
|
44
|
+
'layers-default.md': z.string().min(1).optional(),
|
|
45
|
+
'chat-seed.md': z.string().min(1).optional(),
|
|
46
|
+
}).strict()
|
|
47
|
+
export type PromptOverrides = z.infer<typeof PromptOverridesSchema>
|
|
48
|
+
|
|
49
|
+
export const ProjectConfigSchema = z.object({
|
|
50
|
+
version: z.literal(1),
|
|
51
|
+
rulebook: z.string().min(1).optional(),
|
|
52
|
+
prompts: PromptOverridesSchema.optional(),
|
|
53
|
+
layers: z.array(DefaultLayerSchema),
|
|
54
|
+
highRisk: z.array(HighRiskRuleSchema),
|
|
55
|
+
generation: z.object({
|
|
56
|
+
mode: GenerationModeSchema.default('strict'),
|
|
57
|
+
maxRepairRounds: z.number().int().positive(),
|
|
58
|
+
inlineDiffMaxLines: z.number().int().positive(),
|
|
59
|
+
/** A change set with at most this many hunks is "small": one layer unless concerns differ. */
|
|
60
|
+
smallPrHunks: z.number().int().positive(),
|
|
61
|
+
caps: z.object(capsShape).optional(),
|
|
62
|
+
}),
|
|
63
|
+
/** Which paths count as tests, for the layering rules and the `isTest` flag on a file. */
|
|
64
|
+
tests: z.object({ patterns: z.array(z.string().min(1)) }),
|
|
65
|
+
chat: z.object({ enabled: z.boolean() }),
|
|
66
|
+
})
|
|
67
|
+
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
|
|
68
|
+
|
|
69
|
+
/** The shape a user may write: everything optional, filled from the defaults. */
|
|
70
|
+
const PartialProjectConfigSchema = z.object({
|
|
71
|
+
version: z.literal(1).optional(),
|
|
72
|
+
rulebook: z.string().min(1).optional(),
|
|
73
|
+
prompts: PromptOverridesSchema.optional(),
|
|
74
|
+
layers: z.array(DefaultLayerSchema).optional(),
|
|
75
|
+
highRisk: z.array(HighRiskRuleSchema).optional(),
|
|
76
|
+
generation: z
|
|
77
|
+
.object({
|
|
78
|
+
mode: GenerationModeSchema.optional(),
|
|
79
|
+
maxRepairRounds: z.number().int().positive().optional(),
|
|
80
|
+
inlineDiffMaxLines: z.number().int().positive().optional(),
|
|
81
|
+
smallPrHunks: z.number().int().positive().optional(),
|
|
82
|
+
caps: z.object(capsShape).optional(),
|
|
83
|
+
})
|
|
84
|
+
.optional(),
|
|
85
|
+
tests: z.object({ patterns: z.array(z.string().min(1)).optional() }).optional(),
|
|
86
|
+
chat: z.object({ enabled: z.boolean().optional() }).optional(),
|
|
87
|
+
})
|
|
88
|
+
|
|
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
|
+
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
|
130
|
+
version: 1,
|
|
131
|
+
layers: DEFAULT_LAYERS,
|
|
132
|
+
highRisk: [],
|
|
133
|
+
generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
|
|
134
|
+
tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
|
|
135
|
+
chat: { enabled: true },
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface LoadedProjectConfig {
|
|
139
|
+
config: ProjectConfig
|
|
140
|
+
warnings: string[]
|
|
141
|
+
/** Absolute path of the file that was read, or null when the defaults are in use. */
|
|
142
|
+
source: string | null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatIssues(err: z.ZodError): string {
|
|
146
|
+
return err.issues.map(i => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Merges a parsed user file over the defaults. Exported for tests; `loadProjectConfig` reads the file. */
|
|
150
|
+
export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warnings: string[] } {
|
|
151
|
+
const parsed = PartialProjectConfigSchema.safeParse(raw)
|
|
152
|
+
if (!parsed.success) {
|
|
153
|
+
return {
|
|
154
|
+
config: DEFAULT_PROJECT_CONFIG,
|
|
155
|
+
warnings: [`${PROJECT_CONFIG_FILE} is invalid, using defaults: ${formatIssues(parsed.error)}`],
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const user = parsed.data
|
|
159
|
+
const generation: ProjectConfig['generation'] = {
|
|
160
|
+
mode: user.generation?.mode ?? DEFAULT_PROJECT_CONFIG.generation.mode,
|
|
161
|
+
maxRepairRounds: user.generation?.maxRepairRounds ?? DEFAULT_PROJECT_CONFIG.generation.maxRepairRounds,
|
|
162
|
+
inlineDiffMaxLines: user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
|
|
163
|
+
smallPrHunks: user.generation?.smallPrHunks ?? DEFAULT_PROJECT_CONFIG.generation.smallPrHunks,
|
|
164
|
+
}
|
|
165
|
+
if (user.generation?.caps !== undefined) {
|
|
166
|
+
generation.caps = user.generation.caps
|
|
167
|
+
}
|
|
168
|
+
const config: ProjectConfig = {
|
|
169
|
+
version: 1,
|
|
170
|
+
layers: user.layers ?? DEFAULT_LAYERS,
|
|
171
|
+
highRisk: user.highRisk ?? [],
|
|
172
|
+
generation,
|
|
173
|
+
tests: { patterns: user.tests?.patterns ?? [...DEFAULT_TEST_PATTERNS] },
|
|
174
|
+
chat: { enabled: user.chat?.enabled ?? true },
|
|
175
|
+
}
|
|
176
|
+
if (user.rulebook !== undefined) {
|
|
177
|
+
config.rulebook = user.rulebook
|
|
178
|
+
}
|
|
179
|
+
if (user.prompts !== undefined) {
|
|
180
|
+
config.prompts = user.prompts
|
|
181
|
+
}
|
|
182
|
+
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
|
+
if (config.tests.patterns.length === 0) {
|
|
187
|
+
warnings.push(`${PROJECT_CONFIG_FILE}: "tests.patterns" is empty, so no file counts as a test`)
|
|
188
|
+
}
|
|
189
|
+
const ids = new Set<string>()
|
|
190
|
+
for (const layer of config.layers) {
|
|
191
|
+
if (ids.has(layer.id)) {
|
|
192
|
+
warnings.push(`${PROJECT_CONFIG_FILE}: duplicate layer id "${layer.id}"`)
|
|
193
|
+
}
|
|
194
|
+
ids.add(layer.id)
|
|
195
|
+
}
|
|
196
|
+
return { config, warnings }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function loadProjectConfig(repoRoot: string): Promise<LoadedProjectConfig> {
|
|
200
|
+
const file = path.join(repoRoot, PROJECT_CONFIG_FILE)
|
|
201
|
+
const text = await readText(file)
|
|
202
|
+
if (text === null) {
|
|
203
|
+
return { config: DEFAULT_PROJECT_CONFIG, warnings: [], source: null }
|
|
204
|
+
}
|
|
205
|
+
let raw: unknown
|
|
206
|
+
try {
|
|
207
|
+
raw = parseYaml(text)
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return {
|
|
210
|
+
config: DEFAULT_PROJECT_CONFIG,
|
|
211
|
+
warnings: [
|
|
212
|
+
`${PROJECT_CONFIG_FILE} is not valid YAML, using defaults: ${err instanceof Error ? err.message : String(err)}`,
|
|
213
|
+
],
|
|
214
|
+
source: file,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const merged = mergeProjectConfig(raw ?? {})
|
|
218
|
+
return { ...merged, source: file }
|
|
219
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { PROMPTS_DIR } from './paths.js'
|
|
4
|
+
import type { PromptOverrides } from './project-config.js'
|
|
5
|
+
|
|
6
|
+
export interface ProjectPrompts {
|
|
7
|
+
repoRoot: string
|
|
8
|
+
overrides?: PromptOverrides | undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Explicit overrides must be readable; only omitted entries use bundled templates. */
|
|
12
|
+
export async function loadPromptFile(
|
|
13
|
+
name: keyof PromptOverrides,
|
|
14
|
+
dir = PROMPTS_DIR,
|
|
15
|
+
project?: ProjectPrompts
|
|
16
|
+
): Promise<string> {
|
|
17
|
+
const override = project?.overrides?.[name]
|
|
18
|
+
const file = project !== undefined && override !== undefined
|
|
19
|
+
? path.resolve(project.repoRoot, override)
|
|
20
|
+
: path.join(dir, name)
|
|
21
|
+
try {
|
|
22
|
+
return await readFile(file, 'utf8')
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
throw new Error(`Cannot read prompt ${name} from ${file}`, { cause })
|
|
25
|
+
}
|
|
26
|
+
}
|