@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.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. 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
+ }