@vintasoftware/pr-review-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// Every rule the client relies on, checked here and nowhere else. Pure: the caller passes the
|
|
2
|
+
// hunk index and the config; the report lists one line per problem and never fixes anything.
|
|
3
|
+
import type { z } from 'zod'
|
|
4
|
+
import { extractLinks, type LinkTargets, parseLink, resolveLink } from '../contract/links.js'
|
|
5
|
+
import { diagramKind, mermaidBlocks, withoutMermaid } from '../contract/mermaid-fences.js'
|
|
6
|
+
import {
|
|
7
|
+
type FileEntry,
|
|
8
|
+
type Hunk,
|
|
9
|
+
type Limits,
|
|
10
|
+
type ModelLayer,
|
|
11
|
+
type ModelOutput,
|
|
12
|
+
modelOutputSchema,
|
|
13
|
+
type Side,
|
|
14
|
+
type TextCaps,
|
|
15
|
+
} from '../contract/review-artifact.js'
|
|
16
|
+
import type { ValidationCode, ValidationError, ValidationReport } from '../contract/validation.js'
|
|
17
|
+
import { hunkForLine, hunkLineRanges } from '../git/patch-lines.js'
|
|
18
|
+
import type { HighRiskRule } from '../project-config.js'
|
|
19
|
+
import { diagramNodeIds } from './diagram-nodes.js'
|
|
20
|
+
import { matchesGlob } from './glob.js'
|
|
21
|
+
import { coveredStem, DEFAULT_TEST_PATTERNS, isTestPath, sourceStem } from './test-paths.js'
|
|
22
|
+
import { visibleLength } from './text-length.js'
|
|
23
|
+
import { visiblePrefix } from './trim-caps.js'
|
|
24
|
+
import { validateFolds } from './validate-folds.js'
|
|
25
|
+
|
|
26
|
+
export interface ValidationInput {
|
|
27
|
+
files: readonly FileEntry[]
|
|
28
|
+
caps: TextCaps
|
|
29
|
+
limits: Limits
|
|
30
|
+
highRisk: readonly HighRiskRule[]
|
|
31
|
+
/** Paths that exist at the PR head without being in the diff (for `covered` test paths). */
|
|
32
|
+
headPaths?: ReadonlySet<string>
|
|
33
|
+
/** The globs that make a file a test; the project config's list, or the built-in one. */
|
|
34
|
+
testPatterns?: readonly string[] | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ValidationResult = ValidationReport & { output: ModelOutput | null }
|
|
38
|
+
|
|
39
|
+
function valueAt(root: unknown, keys: readonly PropertyKey[]): unknown {
|
|
40
|
+
let cur: unknown = root
|
|
41
|
+
for (const k of keys) {
|
|
42
|
+
if (typeof cur !== 'object' || cur === null) {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
cur = Reflect.get(cur, k)
|
|
46
|
+
}
|
|
47
|
+
return cur
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The `covered` test paths of a raw output, read before validation so the caller can look them up
|
|
52
|
+
* at the PR head. Anything that is not the expected shape is skipped; the schema names it later.
|
|
53
|
+
*/
|
|
54
|
+
export function coveredTestPaths(raw: unknown): string[] {
|
|
55
|
+
const out: string[] = []
|
|
56
|
+
const layers = valueAt(raw, ['layers'])
|
|
57
|
+
if (!Array.isArray(layers)) {
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
for (const layer of layers) {
|
|
61
|
+
const tests = valueAt(layer, ['tests'])
|
|
62
|
+
if (!Array.isArray(tests)) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
for (const t of tests) {
|
|
66
|
+
const testPath = valueAt(t, ['testPath'])
|
|
67
|
+
if (valueAt(t, ['status']) === 'covered' && typeof testPath === 'string') {
|
|
68
|
+
out.push(testPath)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function schemaErrors(raw: unknown, issues: readonly z.core.$ZodIssue[]): ValidationError[] {
|
|
76
|
+
return issues.map(issue => {
|
|
77
|
+
const where = issue.path.map(String).join('.') || '(root)'
|
|
78
|
+
const value = valueAt(raw, issue.path)
|
|
79
|
+
if (issue.code === 'too_big' && typeof value === 'string' && typeof issue.maximum === 'number') {
|
|
80
|
+
return {
|
|
81
|
+
code: 'TEXT_TOO_LONG',
|
|
82
|
+
message: `${where}: ${value.length} raw chars, hard limit ${issue.maximum}`,
|
|
83
|
+
where,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { code: 'SCHEMA', message: `${where}: ${issue.message}`, where }
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class Report {
|
|
91
|
+
readonly errors: ValidationError[] = []
|
|
92
|
+
add(code: ValidationCode, where: string, message: string): void {
|
|
93
|
+
this.errors.push({ code, message, where })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Index {
|
|
98
|
+
byPath: Map<string, FileEntry>
|
|
99
|
+
byHunkId: Map<string, { file: FileEntry; hunk: Hunk }>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function indexFiles(files: readonly FileEntry[]): Index {
|
|
103
|
+
const byPath = new Map<string, FileEntry>()
|
|
104
|
+
const byHunkId = new Map<string, { file: FileEntry; hunk: Hunk }>()
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
byPath.set(file.path, file)
|
|
107
|
+
for (const hunk of file.hunks) {
|
|
108
|
+
byHunkId.set(hunk.id, { file, hunk })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { byPath, byHunkId }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function layerLabel(layer: ModelLayer): string {
|
|
115
|
+
return layer.kind === 'other' ? 'other' : `layer ${layer.key}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every capped field, measured as the reader sees it: link targets and code markers do not count.
|
|
120
|
+
* The two fields that draw diagrams are measured without their mermaid source, since the reader
|
|
121
|
+
* sees a drawing there; the source answers to the diagram cap instead.
|
|
122
|
+
*/
|
|
123
|
+
function checkLengths(output: ModelOutput, caps: TextCaps, report: Report): void {
|
|
124
|
+
const check = (where: string, key: keyof TextCaps, text: string | undefined, drawn = false): void => {
|
|
125
|
+
if (text === undefined) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const measured = drawn ? withoutMermaid(text) : text
|
|
129
|
+
const length = visibleLength(measured)
|
|
130
|
+
if (length > caps[key]) {
|
|
131
|
+
// Name where the cap falls in their own words, so the rewrite is not guessed at twice.
|
|
132
|
+
const fits = visiblePrefix(measured, caps[key])
|
|
133
|
+
report.add(
|
|
134
|
+
'TEXT_TOO_LONG',
|
|
135
|
+
where,
|
|
136
|
+
`${where}: ${length} visible chars, cap ${caps[key]}; what fits ends at "...${fits.slice(-40)}"`
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
check('summary', 'summary', output.summary, true)
|
|
141
|
+
output.layers.forEach((layer, i) => {
|
|
142
|
+
const at = `layers.${i}`
|
|
143
|
+
check(`${at}.title`, 'layerTitle', layer.title)
|
|
144
|
+
check(`${at}.rationale`, 'rationale', layer.rationale, true)
|
|
145
|
+
check(`${at}.decisions`, 'decisions', layer.decisions)
|
|
146
|
+
check(`${at}.checkByHand`, 'checkByHand', layer.checkByHand)
|
|
147
|
+
layer.tests.forEach((t, j) => {
|
|
148
|
+
check(`${at}.tests.${j}.behavior`, 'testBehavior', t.behavior)
|
|
149
|
+
})
|
|
150
|
+
layer.files.forEach((f, j) => {
|
|
151
|
+
check(`${at}.files.${j}.note`, 'annotation', f.note)
|
|
152
|
+
f.folds?.forEach((fold, k) => {
|
|
153
|
+
const where = `${at}.files.${j}.folds.${k}.title`
|
|
154
|
+
if (fold.title.length > caps.pointTitle) {
|
|
155
|
+
report.add('TEXT_TOO_LONG', where, `${where}: ${fold.title.length} visible chars, cap ${caps.pointTitle}`)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
f.annotations.forEach((a, k) => {
|
|
159
|
+
check(`${at}.files.${j}.annotations.${k}.text`, 'annotation', a.text)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
output.points.forEach((p, i) => {
|
|
164
|
+
check(`points.${i}.title`, 'pointTitle', p.title)
|
|
165
|
+
check(`points.${i}.body`, 'pointBody', p.body)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A diagram per layer and one for the summary, each within the diagram cap. A layer draws its
|
|
171
|
+
* `diagram` field and a ```mermaid block in its rationale; the summary draws a block of its own.
|
|
172
|
+
* Those are the places the page renders, so those are the places counted.
|
|
173
|
+
*/
|
|
174
|
+
function checkDiagrams(output: ModelOutput, caps: TextCaps, limits: Limits, report: Report): void {
|
|
175
|
+
const measure = (field: string, sources: readonly string[]): void => {
|
|
176
|
+
sources.forEach((source, i) => {
|
|
177
|
+
if (source.length > caps.diagram) {
|
|
178
|
+
const at = sources.length === 1 ? field : `${field}.${i + 1}`
|
|
179
|
+
report.add('TEXT_TOO_LONG', at, `${at}: ${source.length} raw chars, cap ${caps.diagram}`)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
const count = (where: string, label: string, n: number): void => {
|
|
184
|
+
if (n > limits.maxDiagramsPerLayer) {
|
|
185
|
+
report.add('DIAGRAM_LIMIT', where, `${label}: ${n} diagrams, at most ${limits.maxDiagramsPerLayer}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const summaryBlocks = mermaidBlocks(output.summary)
|
|
189
|
+
measure('summary.diagram', summaryBlocks)
|
|
190
|
+
count('summary', 'summary', summaryBlocks.length)
|
|
191
|
+
output.layers.forEach((layer, i) => {
|
|
192
|
+
const at = `layers.${i}`
|
|
193
|
+
const blocks = mermaidBlocks(layer.rationale)
|
|
194
|
+
measure(`${at}.rationale.diagram`, blocks)
|
|
195
|
+
let n = blocks.length
|
|
196
|
+
if (layer.diagram !== undefined) {
|
|
197
|
+
measure(`${at}.diagram.mermaid`, [layer.diagram.mermaid])
|
|
198
|
+
n += 1
|
|
199
|
+
}
|
|
200
|
+
count(`layer:${layer.key}`, layerLabel(layer), n)
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function checkHunks(output: ModelOutput, index: Index, report: Report): Map<string, ModelLayer> {
|
|
205
|
+
const owner = new Map<string, ModelLayer>()
|
|
206
|
+
for (const layer of output.layers) {
|
|
207
|
+
const where = `layer:${layer.key}`
|
|
208
|
+
for (const file of layer.files) {
|
|
209
|
+
const entry = index.byPath.get(file.path)
|
|
210
|
+
if (entry === undefined) {
|
|
211
|
+
report.add('PATH_UNKNOWN', where, `${layerLabel(layer)}: ${file.path} is not in the diff`)
|
|
212
|
+
}
|
|
213
|
+
for (const id of file.hunks) {
|
|
214
|
+
const hit = index.byHunkId.get(id)
|
|
215
|
+
if (hit === undefined) {
|
|
216
|
+
const hint = entry === undefined ? '' : ` (${file.path} has ${entry.hunks.length} hunks)`
|
|
217
|
+
report.add('HUNK_UNKNOWN', where, `${layerLabel(layer)}: ${id} does not exist${hint}`)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
if (hit.file.path !== file.path) {
|
|
221
|
+
report.add('HUNK_UNKNOWN', where, `${layerLabel(layer)}: ${id} belongs to ${hit.file.path}, not ${file.path}`)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
const first = owner.get(id)
|
|
225
|
+
if (first !== undefined) {
|
|
226
|
+
report.add(
|
|
227
|
+
'HUNK_DUPLICATE',
|
|
228
|
+
where,
|
|
229
|
+
`${id} (${hit.hunk.header}) is in ${layerLabel(first)} and ${layerLabel(layer)}`
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
owner.set(id, layer)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const { file, hunk } of index.byHunkId.values()) {
|
|
238
|
+
if (!owner.has(hunk.id)) {
|
|
239
|
+
report.add('HUNK_UNASSIGNED', `hunk:${hunk.id}`, `${hunk.id} in ${file.path} (${hunk.header}) is in no layer`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return owner
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function checkLayers(output: ModelOutput, report: Report): void {
|
|
246
|
+
const keys = new Map<string, number>()
|
|
247
|
+
output.layers.forEach((layer, i) => {
|
|
248
|
+
const where = `layer:${layer.key}`
|
|
249
|
+
const seen = keys.get(layer.key)
|
|
250
|
+
if (seen !== undefined) {
|
|
251
|
+
report.add('LAYER_KEY_DUPLICATE', where, `layer ${i + 1}: key "${layer.key}" is also used by layer ${seen + 1}`)
|
|
252
|
+
} else {
|
|
253
|
+
keys.set(layer.key, i)
|
|
254
|
+
}
|
|
255
|
+
if (layer.files.length === 0) {
|
|
256
|
+
report.add('LAYER_EMPTY', where, `${layerLabel(layer)} has no hunks`)
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
// Other is optional: at most one, and last when present.
|
|
260
|
+
const others = output.layers.filter(l => l.kind === 'other')
|
|
261
|
+
const [first, ...extra] = others
|
|
262
|
+
for (const other of extra) {
|
|
263
|
+
report.add(
|
|
264
|
+
'OTHER_DUPLICATE',
|
|
265
|
+
`layer:${other.key}`,
|
|
266
|
+
`layer ${other.key} is a second Other layer; layer ${first?.key ?? ''} already is one`
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
const last = output.layers[output.layers.length - 1]
|
|
270
|
+
if (first !== undefined && first !== last) {
|
|
271
|
+
report.add('OTHER_NOT_LAST', `layer:${first.key}`, `layer ${first.key} (kind other) must be the last layer`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function checkTests(
|
|
276
|
+
output: ModelOutput,
|
|
277
|
+
index: Index,
|
|
278
|
+
headPaths: ReadonlySet<string>,
|
|
279
|
+
testPatterns: readonly string[],
|
|
280
|
+
report: Report
|
|
281
|
+
): void {
|
|
282
|
+
const isTest = (p: string): boolean => isTestPath(p, testPatterns)
|
|
283
|
+
const other = output.layers.find(l => l.kind === 'other')
|
|
284
|
+
const sourceOwner = new Map<string, ModelLayer>()
|
|
285
|
+
for (const layer of output.layers) {
|
|
286
|
+
if (layer.kind === 'other') {
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
for (const file of layer.files) {
|
|
290
|
+
if (!isTest(file.path)) {
|
|
291
|
+
sourceOwner.set(sourceStem(file.path), layer)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const layer of output.layers) {
|
|
296
|
+
const where = `layer:${layer.key}`
|
|
297
|
+
let firstTest: string | null = null
|
|
298
|
+
for (const file of layer.files) {
|
|
299
|
+
if (isTest(file.path)) {
|
|
300
|
+
firstTest ??= file.path
|
|
301
|
+
} else if (firstTest !== null) {
|
|
302
|
+
report.add('TEST_NOT_LAST', where, `${layerLabel(layer)}: ${firstTest} precedes ${file.path}`)
|
|
303
|
+
firstTest = null
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const t of layer.tests) {
|
|
307
|
+
if (t.status !== 'covered') {
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
if (t.testPath === undefined) {
|
|
311
|
+
report.add('TEST_PATH_UNKNOWN', where, `${layerLabel(layer)}: "${t.behavior}" is covered but names no testPath`)
|
|
312
|
+
} else if (!(index.byPath.has(t.testPath) || headPaths.has(t.testPath))) {
|
|
313
|
+
report.add('TEST_PATH_UNKNOWN', where, `${layerLabel(layer)}: ${t.testPath} is not in the PR head`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (other !== undefined) {
|
|
318
|
+
for (const file of other.files) {
|
|
319
|
+
if (!isTest(file.path)) {
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
const owner = sourceOwner.get(coveredStem(file.path))
|
|
323
|
+
if (owner !== undefined) {
|
|
324
|
+
const source = owner.files.find(f => sourceStem(f.path) === coveredStem(file.path))?.path ?? ''
|
|
325
|
+
report.add(
|
|
326
|
+
'TEST_IN_OTHER',
|
|
327
|
+
`layer:${other.key}`,
|
|
328
|
+
`other: ${file.path} covers ${source}, which is in layer ${owner.key}`
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function checkRisk(output: ModelOutput, highRisk: readonly HighRiskRule[], report: Report): void {
|
|
336
|
+
const other = output.layers.find(l => l.kind === 'other')
|
|
337
|
+
if (other === undefined) {
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
const where = `layer:${other.key}`
|
|
341
|
+
for (const tag of other.risk ?? []) {
|
|
342
|
+
report.add('RISK_IN_OTHER', where, `other: carries the risk tag "${tag.label}"; move the hunks to a real layer`)
|
|
343
|
+
}
|
|
344
|
+
for (const file of other.files) {
|
|
345
|
+
const rule = highRisk.find(r => matchesGlob(r.pattern, file.path))
|
|
346
|
+
if (rule !== undefined) {
|
|
347
|
+
report.add('RISK_IN_OTHER', where, `other: ${file.path} matches highRisk "${rule.pattern}" (${rule.label})`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hunkAt(file: FileEntry | undefined, side: Side, line: number): Hunk | null {
|
|
353
|
+
return file === undefined ? null : hunkForLine(file.hunks, side, line)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function checkAnnotations(output: ModelOutput, index: Index, report: Report): void {
|
|
357
|
+
for (const layer of output.layers) {
|
|
358
|
+
for (const file of layer.files) {
|
|
359
|
+
const entry = index.byPath.get(file.path)
|
|
360
|
+
file.annotations.forEach((a, i) => {
|
|
361
|
+
const where = `layer:${layer.key}/annotation:${file.path}:${i + 1}`
|
|
362
|
+
const range = a.startLine === a.endLine ? `${a.startLine}` : `${a.startLine}-${a.endLine}`
|
|
363
|
+
const label = `${layerLabel(layer)} ${file.path}:${range} (${a.side})`
|
|
364
|
+
if (a.endLine < a.startLine) {
|
|
365
|
+
report.add('ANNOTATION_OUTSIDE_HUNK', where, `${label}: endLine is before startLine`)
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
const start = hunkAt(entry, a.side, a.startLine)
|
|
369
|
+
const end = hunkAt(entry, a.side, a.endLine)
|
|
370
|
+
if (start === null || end === null || start.id !== end.id) {
|
|
371
|
+
report.add('ANNOTATION_OUTSIDE_HUNK', where, `${label} is not inside one hunk of the diff (${hunkLineRanges(entry?.hunks ?? [], a.side)})`)
|
|
372
|
+
} else if (!file.hunks.includes(start.id)) {
|
|
373
|
+
report.add('ANNOTATION_OUTSIDE_HUNK', where, `${label} is in ${start.id}, which this layer does not list`)
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function checkPoints(output: ModelOutput, index: Index, limits: Limits, report: Report): void {
|
|
381
|
+
const missingTests = output.layers.reduce((n, l) => n + l.tests.filter(t => t.status === 'missing').length, 0)
|
|
382
|
+
const total = output.points.length + missingTests
|
|
383
|
+
if (total > limits.maxPoints) {
|
|
384
|
+
report.add(
|
|
385
|
+
'TOO_MANY_POINTS',
|
|
386
|
+
'points',
|
|
387
|
+
`${total} attention points (${output.points.length} written + ${missingTests} from missing tests), cap ${limits.maxPoints}`
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
output.points.forEach((p, i) => {
|
|
391
|
+
const where = `point:${i + 1}`
|
|
392
|
+
const side = p.side ?? 'new'
|
|
393
|
+
const entry = index.byPath.get(p.path)
|
|
394
|
+
const start = hunkAt(entry, side, p.line)
|
|
395
|
+
if (entry === undefined) {
|
|
396
|
+
report.add('POINT_OUTSIDE_DIFF', where, `point ${i + 1} "${p.title}": ${p.path} is not in the diff`)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
if (start === null) {
|
|
400
|
+
report.add(
|
|
401
|
+
'POINT_OUTSIDE_DIFF',
|
|
402
|
+
where,
|
|
403
|
+
`point ${i + 1} "${p.title}": ${p.path}:${p.line} (${side}) is not in the diff (${hunkLineRanges(entry.hunks, side)})`
|
|
404
|
+
)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (p.endLine !== undefined && p.endLine < p.line) {
|
|
408
|
+
report.add('POINT_OUTSIDE_DIFF', where, `point ${i + 1} "${p.title}": endLine is before line`)
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
if (p.endLine !== undefined && hunkAt(entry, side, p.endLine)?.id !== start.id) {
|
|
412
|
+
report.add(
|
|
413
|
+
'POINT_OUTSIDE_DIFF',
|
|
414
|
+
where,
|
|
415
|
+
`point ${i + 1} "${p.title}": ${p.path}:${p.line}-${p.endLine} (${side}) crosses out of ${start.id} (${hunkLineRanges(entry.hunks, side)})`
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** One link, parsed and resolved against the artifact; a problem reads as one line. */
|
|
422
|
+
function checkLink(report: Report, targets: LinkTargets, where: string, label: string, href: string): void {
|
|
423
|
+
const parsed = parseLink(href)
|
|
424
|
+
if (parsed === null) {
|
|
425
|
+
report.add('LINK_UNRESOLVED', where, `${label}: ${href} is not one of the four link forms`)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
const resolved = resolveLink(parsed, targets)
|
|
429
|
+
if (!resolved.ok) {
|
|
430
|
+
report.add('LINK_UNRESOLVED', where, `${label}: ${href} ${resolved.message}`)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function checkLinks(output: ModelOutput, files: readonly FileEntry[], report: Report): void {
|
|
435
|
+
const targets: LinkTargets = { layers: output.layers, files }
|
|
436
|
+
const check = (where: string, label: string, text: string | undefined): void => {
|
|
437
|
+
for (const href of extractLinks(text ?? '')) {
|
|
438
|
+
checkLink(report, targets, where, label, href)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
check('summary', 'summary', output.summary)
|
|
442
|
+
for (const layer of output.layers) {
|
|
443
|
+
const where = `layer:${layer.key}`
|
|
444
|
+
const label = layerLabel(layer)
|
|
445
|
+
check(where, `${label} rationale`, layer.rationale)
|
|
446
|
+
check(where, `${label} decisions`, layer.decisions)
|
|
447
|
+
check(where, `${label} checkByHand`, layer.checkByHand)
|
|
448
|
+
for (const t of layer.tests) {
|
|
449
|
+
check(where, `${label} test "${t.behavior}"`, t.note)
|
|
450
|
+
}
|
|
451
|
+
for (const file of layer.files) {
|
|
452
|
+
check(where, `${label} ${file.path} note`, file.note)
|
|
453
|
+
file.annotations.forEach((a, i) => {
|
|
454
|
+
check(where, `${label} ${file.path} annotation ${i + 1}`, a.text)
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
output.points.forEach((p, i) => {
|
|
459
|
+
check(`point:${i + 1}`, `point ${i + 1} "${p.title}"`, p.body)
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The sidecar map of a layer's diagram: each key names a node of the mermaid source, each value
|
|
465
|
+
* is a canvas link, and a diagram carries at most `maxDiagramLinks` of them.
|
|
466
|
+
*/
|
|
467
|
+
function checkDiagramLinks(output: ModelOutput, files: readonly FileEntry[], limits: Limits, report: Report): void {
|
|
468
|
+
const targets: LinkTargets = { layers: output.layers, files }
|
|
469
|
+
for (const layer of output.layers) {
|
|
470
|
+
if (layer.diagram === undefined) {
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
const where = `layer:${layer.key}`
|
|
474
|
+
const label = `${layerLabel(layer)} diagram`
|
|
475
|
+
const entries = Object.entries(layer.diagram.links)
|
|
476
|
+
if (entries.length > limits.maxDiagramLinks) {
|
|
477
|
+
report.add('DIAGRAM_LIMIT', where, `${label}: ${entries.length} node links, at most ${limits.maxDiagramLinks}`)
|
|
478
|
+
}
|
|
479
|
+
const kind = diagramKind(layer.diagram.mermaid)
|
|
480
|
+
const nodes = diagramNodeIds(layer.diagram.mermaid)
|
|
481
|
+
for (const [nodeId, href] of entries) {
|
|
482
|
+
if (!nodes.has(nodeId)) {
|
|
483
|
+
const what = kind === '' ? 'the source' : `the ${kind} source`
|
|
484
|
+
report.add('DIAGRAM_NODE_UNKNOWN', where, `${label}: "${nodeId}" is not a node of ${what}`)
|
|
485
|
+
}
|
|
486
|
+
checkLink(report, targets, where, `${label} node "${nodeId}"`, href)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Schema first (a shape failure ends the run, since the rules need a well-formed output; raw text
|
|
493
|
+
* past the hard limit counts as one), then the caps on the visible text and the layering rules
|
|
494
|
+
* against the hunk index, all reported in one round.
|
|
495
|
+
*/
|
|
496
|
+
export function validateModelOutput(raw: unknown, input: ValidationInput): ValidationResult {
|
|
497
|
+
const parsed = modelOutputSchema(input.caps).safeParse(raw)
|
|
498
|
+
if (!parsed.success) {
|
|
499
|
+
return { ok: false, errors: schemaErrors(raw, parsed.error.issues), output: null }
|
|
500
|
+
}
|
|
501
|
+
const output = parsed.data
|
|
502
|
+
const report = new Report()
|
|
503
|
+
checkLengths(output, input.caps, report)
|
|
504
|
+
checkDiagrams(output, input.caps, input.limits, report)
|
|
505
|
+
const index = indexFiles(input.files)
|
|
506
|
+
checkHunks(output, index, report)
|
|
507
|
+
checkLayers(output, report)
|
|
508
|
+
checkTests(output, index, input.headPaths ?? new Set(), input.testPatterns ?? DEFAULT_TEST_PATTERNS, report)
|
|
509
|
+
checkRisk(output, input.highRisk, report)
|
|
510
|
+
checkAnnotations(output, index, report)
|
|
511
|
+
for (const error of validateFolds(output, input.files)) {
|
|
512
|
+
report.add(error.code, error.where ?? 'folds', error.message)
|
|
513
|
+
}
|
|
514
|
+
checkPoints(output, index, input.limits, report)
|
|
515
|
+
checkLinks(output, input.files, report)
|
|
516
|
+
checkDiagramLinks(output, input.files, input.limits, report)
|
|
517
|
+
return report.errors.length === 0
|
|
518
|
+
? { ok: true, errors: [], output }
|
|
519
|
+
: { ok: false, errors: report.errors, output: null }
|
|
520
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Context, Hono } from 'hono'
|
|
2
|
+
import type { AppContext } from './context.js'
|
|
3
|
+
import type { AppEnv } from './env.js'
|
|
4
|
+
import { AppError, toAppError } from './errors.js'
|
|
5
|
+
import { errorPage } from './html.js'
|
|
6
|
+
import { apiRoutes } from './routes/api.js'
|
|
7
|
+
import { appearanceFor, appearanceQuery, pageRoutes } from './routes/pages.js'
|
|
8
|
+
import { staticRoutes } from './routes/static.js'
|
|
9
|
+
import { applyResponseHeaders, createNonce, responseHeaders, securityMiddleware } from './security.js'
|
|
10
|
+
|
|
11
|
+
/** Errors under /api and /vendor answer with the JSON envelope; pages render the error page. */
|
|
12
|
+
function wantsJson(pathname: string): boolean {
|
|
13
|
+
return pathname.startsWith('/api/') || pathname.startsWith('/vendor/') || pathname.startsWith('/static/')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createApp(ctx: AppContext): Hono<AppEnv> {
|
|
17
|
+
const app = new Hono<AppEnv>()
|
|
18
|
+
|
|
19
|
+
app.use('*', responseHeaders)
|
|
20
|
+
app.use('*', securityMiddleware)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An error skips the code after `await next()` in the middleware above, so the answers built
|
|
24
|
+
* here carry the same headers. A rejected Host never reached the nonce either, hence the
|
|
25
|
+
* fallback.
|
|
26
|
+
*/
|
|
27
|
+
const errorResponse = async (c: Context<AppEnv>, err: AppError): Promise<Response> => {
|
|
28
|
+
const envelope = err.toEnvelope()
|
|
29
|
+
const nonce = c.get('cspNonce') ?? createNonce()
|
|
30
|
+
const res = wantsJson(c.req.path)
|
|
31
|
+
? c.json(envelope, err.status)
|
|
32
|
+
: await c.html(errorPage(envelope.error, nonce, await appearanceFor(ctx, appearanceQuery(c))), err.status)
|
|
33
|
+
applyResponseHeaders(res, c.req.path, nonce)
|
|
34
|
+
return res
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
app.onError((err, c) => errorResponse(c, toAppError(err)))
|
|
38
|
+
|
|
39
|
+
app.notFound(c => errorResponse(c, new AppError('NOT_FOUND', `no route for ${c.req.method} ${c.req.path}`, 404)))
|
|
40
|
+
|
|
41
|
+
app.route('/api', apiRoutes(ctx))
|
|
42
|
+
app.route('/', staticRoutes(ctx))
|
|
43
|
+
app.route('/', pageRoutes(ctx))
|
|
44
|
+
|
|
45
|
+
return app
|
|
46
|
+
}
|