@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20

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 (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1330 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +25 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,273 @@
1
+ // One finding as markdown: its heading, the facts that aren't prose as a
2
+ // labelled list, then the narrative in the order a reader needs it. The
3
+ // document (write-md.js) hands in the heading text and the depth; what a
4
+ // finding CARRIES is read here, off the parser's object through
5
+ // finding.js.
6
+ //
7
+ // Nothing is dropped for being unfamiliar to the viewer — a report's
8
+ // status and branch, an audit's PoC state and commit land on the list
9
+ // beside the facts every card shows, and each narrative field gets a
10
+ // section rather than a bold label buried in a paragraph.
11
+ //
12
+ // A dedup group — one finding reported several times — is one heading
13
+ // with a case under it per member, so the reader meets the finding once
14
+ // and its reports as its cases.
15
+
16
+ import { correctedVariants, descriptionSections, displayedSeverity, effectiveSeverity, evidenceNote, findingDisplayName, findingTitle, firstLine, hasSeverityCorrection, locationLabel, revalidateKindOf, runMetaLine, splitDescription, stripExportMarker } from './finding.js'
17
+ import { COLOR_LABELS, SOURCE_LABELS, TRIAGE_LABELS, severityLabel } from './labels.js'
18
+ import { autolink, code, heading, indentUnder, isHttpUrl, joinBlocks, link, plural, prose } from './md-text.js'
19
+ import { isRepoSlug } from './meta.js'
20
+ import { normalizeNewlines } from './md-structure.js'
21
+
22
+ // A heading has to fit on a line, and a JSON finding whose description
23
+ // is one paragraph is NAMED by that paragraph. Past this it is cut, and
24
+ // the body carries the whole name (descriptionBlocks).
25
+ const HEADING_MAX = 120
26
+
27
+ export function findingHeading(f) {
28
+ const title = findingTitle(f) || locationLabel(f) || 'Untitled finding'
29
+ return title.length > HEADING_MAX ? `${title.slice(0, HEADING_MAX - 1).trimEnd()}…` : title
30
+ }
31
+
32
+ // A repository as a link — a github.com slug points at github.com, a
33
+ // URL at itself, anything else stays the text it is.
34
+ export function repoRef(repo) {
35
+ const s = String(repo ?? '').trim()
36
+ if (isHttpUrl(s)) return autolink(s)
37
+ return isRepoSlug(s) ? link(s, `https://github.com/${s}`) : s
38
+ }
39
+
40
+ // What produced the finding, as the document names it: a product, which
41
+ // is one analyzer with no runs to tell apart, so its name is the whole
42
+ // answer — or, out of the analyzer's own dump, the run itself (finding.js
43
+ // runMetaLine). What a report filed a finding UNDER, Claude Security's
44
+ // `**Category:**`, is not its analyzer and gets its own line.
45
+ export function analyzerText(f, source, revalidation) {
46
+ if (source) return SOURCE_LABELS[source] ?? String(source)
47
+ return runMetaLine(f, revalidation)
48
+ }
49
+
50
+ // The narrative fields beyond the description, `[heading, field, pass]`
51
+ // in the order the card reads them: what it means, how to trigger it,
52
+ // how to fix it, then the analyzer's and the pass's remarks. The pass's
53
+ // two travel with the revalidation layer (ctx.revalidation).
54
+ const NARRATIVE = [
55
+ ['Impact', 'impact', false],
56
+ ['Reproduction', 'reproduction', false],
57
+ ['Recommendation', 'recommendation', false],
58
+ ['Confidence reasoning', 'confidenceReason', false],
59
+ ['Revalidation verdict', 'revalidateVerdict', true],
60
+ ['Revalidation recommendation', 'revalidateRecommendation', true],
61
+ ]
62
+
63
+ // The plain facts a report may attach, `[label, field]`, printed as
64
+ // written and under the name the report used, so a reader of the
65
+ // original recognises each: Claude Security's `Status` / `Branch` /
66
+ // `Date created`, Codex's `detected_at`, Piolium's `PoC status` /
67
+ // `Variant of`, DeepSec's `Slug`. Strings and numbers only — an object
68
+ // has no line to print on.
69
+ const PLAIN_FIELDS = [
70
+ ['Status', 'status'], ['Branch', 'branch'], ['Date created', 'dateCreated'],
71
+ ['Detected at', 'detectedAt'], ['Committed at', 'committedAt'],
72
+ ['PoC status', 'pocStatus'], ['Variant of', 'parent'], ['Slug', 'slug'],
73
+ ['Priority', 'priority'],
74
+ ]
75
+ // …and the ones that are paths or hashes, set in code.
76
+ const CODE_FIELDS = [['Detailed report', 'reportPath'], ['Commit audited', 'auditedCommit']]
77
+
78
+ // A fact is one line of the list, so a value that arrived with line
79
+ // breaks (a wrapped Piolium bullet) is reflowed onto one — the break
80
+ // would end the list. Prose keeps its lines (proseValue).
81
+ function plainValue(v) {
82
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
83
+ return typeof v === 'string' ? v.replaceAll(/\s*\n\s*/gu, ' ').trim() : ''
84
+ }
85
+
86
+ function proseValue(v) {
87
+ return typeof v === 'string' ? v.trim() : ''
88
+ }
89
+
90
+ // The severity under the reader's lens, with a corrected finding's other
91
+ // value beside it — the document has no toggle, so both are on the page —
92
+ // and the per-report divergence a workspace merge can carry.
93
+ function severityText(f, ctx) {
94
+ const original = ctx.severityMode === 'original'
95
+ let text = severityLabel(displayedSeverity(f, ctx.severityMode))
96
+ if (hasSeverityCorrection(f)) {
97
+ text += original
98
+ ? ` — corrected to ${severityLabel(effectiveSeverity(f))}`
99
+ : ` — corrected from ${severityLabel(f.severity)}`
100
+ }
101
+ const variants = correctedVariants(f)
102
+ if (variants) {
103
+ const list = Object.entries(variants).map(([r, v]) => `${r || 'this report'}: ${severityLabel(v?.severity)}`)
104
+ text += ` (varies across reports — ${list.join('; ')})`
105
+ }
106
+ if (f.critical === true) text += ' · flagged critical by the analyzer'
107
+ return text
108
+ }
109
+
110
+ function locationText(f, ctx) {
111
+ const label = locationLabel(f)
112
+ if (!label) return ''
113
+ const url = ctx.hooks.location(f)
114
+ const ref = isHttpUrl(url) ? link(code(label), url) : code(label)
115
+ const name = findingDisplayName(f)
116
+ return name ? `${ref} · ${code(name)}` : ref
117
+ }
118
+
119
+ // What the reader did with the finding: its triage bucket (or the
120
+ // per-report ignore), its colour mark, its flag — one line.
121
+ function triageText(a) {
122
+ if (!a) return ''
123
+ const parts = []
124
+ const bucket = TRIAGE_LABELS[a.triage] ?? (a.ignored ? TRIAGE_LABELS.ignored : '')
125
+ if (bucket) parts.push(bucket)
126
+ if (a.color) parts.push(`${COLOR_LABELS[a.color] ?? a.color} mark`)
127
+ if (a.flagged === true) parts.push('Flagged')
128
+ return parts.join(' · ')
129
+ }
130
+
131
+ // Whose revalidation pass a stamp came from, in the words this document
132
+ // spells a producer with; an unknown key prints as itself. Written under
133
+ // the stamp, so it travels with the layer and says nothing where the
134
+ // finding's own report ran the pass.
135
+ function sourceText(source) {
136
+ const s = plainValue(source)
137
+ return s ? SOURCE_LABELS[s] ?? s : ''
138
+ }
139
+
140
+ function commitText(f, ctx) {
141
+ const hash = plainValue(f.commitHash)
142
+ if (!hash) return ''
143
+ const url = ctx.hooks.commit(f)
144
+ return isHttpUrl(url) ? link(code(hash.slice(0, 7)), url) : code(hash)
145
+ }
146
+
147
+ // The labelled list under a finding's heading: every fact that isn't
148
+ // prose, in the order the card's rail reads them, then the provenance
149
+ // the report attached, then the id — the one fact that means nothing to
150
+ // a reader and everything to the reader of the file, which keys stored
151
+ // triage off it. A line is written only when its fact is there.
152
+ function metaList(f, ctx, annotation) {
153
+ const rows = []
154
+ const add = (label, value) => { if (value) rows.push(`- **${label}:** ${value}`) }
155
+ add('Location', locationText(f, ctx))
156
+ add('Severity', severityText(f, ctx))
157
+ if (f.confidence !== undefined && f.confidence !== null) add('Confidence', `${f.confidence}/10`)
158
+ if (ctx.showAnalyzer) add('Analyzer', ctx.analyzerOf(f))
159
+ add('Category', plainValue(f.category))
160
+ const kind = ctx.revalidation ? revalidateKindOf(f) : ''
161
+ if (kind) add('Revalidation', kind === 'revalidation' ? 'the revalidation pass itself' : kind)
162
+ if (kind) add('Revalidated by', sourceText(f.revalidateSource))
163
+ add('Triage', triageText(annotation))
164
+ if (annotation?.fix) add('Fix', autolink(String(annotation.fix).trim()))
165
+ if (ctx.showReport) add('Report', code(ctx.hooks.report(f) ?? ''))
166
+ const repo = plainValue(f.repo?.github)
167
+ if (repo && repo !== ctx.repo) add('Repository', repoRef(repo))
168
+ add('Introduced in', commitText(f, ctx))
169
+ const found = plainValue(f.discoveredIn)
170
+ if (found && found !== String(f.file ?? '').trim()) add('Found while analyzing', code(found))
171
+ const npm = f.package?.npm
172
+ const pkg = plainValue(npm?.name)
173
+ if (pkg) add('Package', code(plainValue(npm.version) ? `${pkg}@${plainValue(npm.version)}` : pkg))
174
+ for (const [label, field] of PLAIN_FIELDS) add(label, plainValue(f[field]))
175
+ for (const [label, field] of CODE_FIELDS) add(label, code(plainValue(f[field])))
176
+ add('ID', code(plainValue(f.id)))
177
+ return rows.join('\n')
178
+ }
179
+
180
+ function section(depth, label, text) {
181
+ const body = prose(text)
182
+ return body ? `${heading(depth, label)}\n\n${body}` : heading(depth, label)
183
+ }
184
+
185
+ // The `## Evidence` rows as a loose numbered list: the reference, linked
186
+ // where the caller can, and the report's note as its own paragraph under
187
+ // it — loose, or a note sharing the reference's line would be reflowed
188
+ // onto it.
189
+ function evidenceList(f, ctx) {
190
+ const rows = Array.isArray(f.evidence) ? f.evidence : []
191
+ return rows.map((row, i) => {
192
+ const marker = `${i + 1}. `
193
+ const label = locationLabel(row)
194
+ const url = ctx.hooks.evidence(row, f, i)
195
+ let ref = ''
196
+ if (label) ref = isHttpUrl(url) ? link(code(label), url) : code(label)
197
+ else if (isHttpUrl(url)) ref = autolink(url)
198
+ const note = prose(evidenceNote(row))
199
+ const head = marker + (ref || '(no reference)')
200
+ return note ? `${head}\n\n${indentUnder(marker, note)}` : head
201
+ }).join('\n\n')
202
+ }
203
+
204
+ // The description's lead, its evidence, then the labelled sections the
205
+ // report wrote — the order a claude-security report writes and the card
206
+ // reads. A `**Label:**` paragraph becomes a section with a heading, as
207
+ // the finding's own impact / reproduction fields do, so a report that
208
+ // wrote those as fields and one that wrote them into its prose read
209
+ // identically.
210
+ function descriptionBlocks(f, ctx, depth) {
211
+ const split = splitDescription(f)
212
+ // Line endings first: the paragraph split below reads blank lines,
213
+ // and a `\r\n\r\n` a JSON report wrote is not one to it.
214
+ const body = normalizeNewlines(split.body)
215
+ // A one-line description IS the heading, and printing it again is a
216
+ // stutter — unless the heading could not carry the whole name
217
+ // (HEADING_MAX), where the body opens on it instead, the only place
218
+ // the whole name appears and where the file's reader finds it.
219
+ const title = findingTitle(f)
220
+ const cut = title !== '' && findingHeading(f) !== title
221
+ const stutter = !cut && !split.title && body.trim() === title
222
+ const carried = cut && firstLine(body) !== title ? `${title}\n\n${body}` : body
223
+ const sections = descriptionSections(stutter ? '' : carried)
224
+ const firstLabel = sections.findIndex((s) => s.label !== null)
225
+ const lead = firstLabel === -1 ? sections : sections.slice(0, firstLabel)
226
+ const rest = firstLabel === -1 ? [] : sections.slice(firstLabel)
227
+ const blocks = lead.map((s) => prose(s.body))
228
+ const evidence = evidenceList(f, ctx)
229
+ if (evidence) blocks.push(`${heading(depth, 'Evidence')}\n\n${evidence}`)
230
+ for (const s of rest) blocks.push(s.label === null ? prose(s.body) : section(depth, s.label, s.body))
231
+ return blocks
232
+ }
233
+
234
+ // Everything under one case's heading: the facts, the description, the
235
+ // narrative sections, then the reader's comment — about the finding
236
+ // rather than part of it.
237
+ function caseBlocks(f, ctx, depth) {
238
+ const annotation = ctx.hooks.annotation(f)
239
+ const blocks = [metaList(f, ctx, annotation), ...descriptionBlocks(f, ctx, depth)]
240
+ for (const [label, field, pass] of NARRATIVE) {
241
+ if (pass && !ctx.revalidation) continue
242
+ const raw = f[field]
243
+ const value = typeof raw === 'string' ? stripExportMarker(raw, f) : ''
244
+ if (value.trim()) blocks.push(section(depth, label, value))
245
+ }
246
+ if (hasSeverityCorrection(f) && proseValue(f.correctedSeverityReason)) {
247
+ blocks.push(section(depth, 'Severity correction', f.correctedSeverityReason))
248
+ }
249
+ const comment = proseValue(annotation?.comment)
250
+ if (comment) blocks.push(section(depth, 'Comment', comment))
251
+ return blocks
252
+ }
253
+
254
+ // One group under its heading: a single case writes straight under it,
255
+ // several get a numbered, located heading each with their sections one
256
+ // level down. A case named differently from the group says so under its
257
+ // own heading.
258
+ export function groupSection(group, ctx, { headingText, depth }) {
259
+ const blocks = [heading(depth, headingText)]
260
+ if (group.length === 1) return joinBlocks([...blocks, ...caseBlocks(group[0], ctx, depth + 1)])
261
+ const reports = [...new Set(group.map((f) => ctx.hooks.report(f)).filter(Boolean))]
262
+ const from = reports.length > 1 ? ` — reported in ${reports.map((r) => code(r)).join(', ')}` : ''
263
+ blocks.push(`${plural(group.length, 'case')} of this finding${from}.`)
264
+ const groupTitle = findingTitle(group[0])
265
+ group.forEach((f, i) => {
266
+ const loc = locationLabel(f)
267
+ blocks.push(heading(depth + 1, `Case ${i + 1} of ${group.length}${loc ? ` — ${code(loc)}` : ''}`))
268
+ const own = findingTitle(f)
269
+ if (own && own !== groupTitle) blocks.push(prose(own))
270
+ blocks.push(...caseBlocks(f, ctx, depth + 2))
271
+ })
272
+ return joinBlocks(blocks)
273
+ }
@@ -0,0 +1,291 @@
1
+ // The findings document — what the "Download" button writes: one
2
+ // markdown file a person can read top to bottom or jump around in.
3
+ //
4
+ // The shape, top to bottom:
5
+ //
6
+ // <!-- DeepView findings export -->
7
+ // # <title>
8
+ // - **Source:** … / **Report:** … / **Repository:** … / **Analyzer:** …
9
+ // - **Exported:** … / **View:** … / **Filters:** … / **Included:** N of M findings
10
+ //
11
+ // ## Summary
12
+ // <severity counts> <annotation counts> <index of findings, linked>
13
+ //
14
+ // ## Critical (2)
15
+ // ### 1. <finding> ← write-md-finding.js from here down
16
+ // - **Location:** … the facts
17
+ // <description> #### Evidence #### Impact …
18
+ //
19
+ // The header is the honest part. An export is a SELECTION — the triage
20
+ // view narrowed by the toolbar filters — and a reader who wasn't at the
21
+ // screen has to be told that a file of 12 findings is 12 of 40, and
22
+ // which 28 are missing and why. So the filters ride in the header, in
23
+ // the confirmation dialog's own words and counts.
24
+ //
25
+ // The document is also a report this library READS
26
+ // (parse-deepview-md.js): the first line marks it, every finding carries
27
+ // its id, and what the facts and sections say is what comes back,
28
+ // whichever format the findings first arrived in. So a value goes on the
29
+ // page in a shape the reader can take back off it — a fact on one line,
30
+ // a location in a code span, a line of prose that would read as a
31
+ // heading escaped.
32
+ //
33
+ // `doc` is plain data the caller assembles (ui/view/markdown-export.js,
34
+ // or anything else holding findings out of index.js); `hooks` are the
35
+ // few answers only the caller has — where a location links, what a
36
+ // reader wrote on a finding, which report a case came from. All
37
+ // optional: the defaults link what the report linked and annotate
38
+ // nothing.
39
+ //
40
+ // writeMarkdown({
41
+ // title, workspace, reports: [{ name, source }], repo, generatedAt,
42
+ // view: { bucket, severityMode, revalidation, revalidationDetail },
43
+ // filters: [{ label, value }], counts: { included, total },
44
+ // groups: [ [finding, …], … ], // display order, primary case first
45
+ // }, { annotation, location, evidence, commit, report })
46
+
47
+ import { SEVERITIES, displayedSeverity, locationLabel } from './finding.js'
48
+ import { SOURCE_LABELS, severityLabel } from './labels.js'
49
+ import { analyzerText, findingHeading, groupSection, repoRef } from './write-md-finding.js'
50
+ import { anchorSlug, cell, code, escapeBrackets, formatTimestamp, heading, joinBlocks, link, plural, table } from './md-text.js'
51
+
52
+ // The first line of every document this writes, and what its reader keys
53
+ // on: an HTML comment, invisible rendered, and no other format begins
54
+ // with one. The reader matches the phrase and reads past what follows,
55
+ // so a later document that must be told apart can say so after a comma.
56
+ export const DOCUMENT_MARKER = '<!-- DeepView findings export -->'
57
+
58
+ // What a caller can answer about a finding, and what is assumed when
59
+ // it doesn't: a report's own link for a location or an evidence row
60
+ // still links, nothing else does, and nothing is annotated.
61
+ const DEFAULT_HOOKS = {
62
+ annotation: () => null,
63
+ location: (f) => (typeof f?.location === 'string' ? f.location : null),
64
+ evidence: (row) => (typeof row?.url === 'string' ? row.url : null),
65
+ commit: () => null,
66
+ report: () => null,
67
+ }
68
+
69
+ function withDefaults(hooks) {
70
+ const out = {}
71
+ for (const [name, fallback] of Object.entries(DEFAULT_HOOKS)) {
72
+ out[name] = typeof hooks?.[name] === 'function' ? hooks[name] : fallback
73
+ }
74
+ return out
75
+ }
76
+
77
+ // Which producer a finding came from — a `source` marker, null for the
78
+ // analyzer's own dump. Its own when it carries one: a re-imported
79
+ // document that mixed products stamps each product's findings, and a
80
+ // finding stays that product's whatever report it now sits in.
81
+ // Otherwise its report's, found by the `report` hook's name in
82
+ // `doc.reports` when the document holds more than one.
83
+ function sourceReader(reports, hooks) {
84
+ const own = (f) => (typeof f?.source === 'string' && f.source ? f.source : null)
85
+ if (reports.length === 1) return (f) => own(f) ?? reports[0].source ?? null
86
+ const byName = new Map(reports.map((r) => [r.name, r.source ?? null]))
87
+ return (f) => own(f) ?? byName.get(hooks.report(f)) ?? null
88
+ }
89
+
90
+ // The per-document decisions, made once: the severity lens, whether the
91
+ // revalidation layer is applied, and whether the per-finding analyzer
92
+ // and report lines say anything — written only where they vary, so a
93
+ // single-run report isn't told forty times which run it was.
94
+ function buildContext(doc, hooks, cases) {
95
+ const revalidation = doc.view?.revalidation !== false
96
+ const reports = (Array.isArray(doc.reports) ? doc.reports : []).filter((r) => r && typeof r === 'object')
97
+ const sourceOf = sourceReader(reports, hooks)
98
+ const analyzerOf = (f) => analyzerText(f, sourceOf(f), revalidation)
99
+ const analyzers = new Set(cases.map(analyzerOf))
100
+ const names = new Set(cases.map((f) => hooks.report(f)).filter(Boolean))
101
+ return {
102
+ hooks,
103
+ analyzerOf,
104
+ severityMode: doc.view?.severityMode === 'original' ? 'original' : 'corrected',
105
+ revalidation,
106
+ showAnalyzer: analyzers.size > 1,
107
+ showReport: names.size > 1 || reports.length > 1,
108
+ repo: typeof doc.repo === 'string' && doc.repo ? doc.repo : null,
109
+ }
110
+ }
111
+
112
+ // The view the selection was made in, as one line: which triage
113
+ // bucket, which severity lens (when the set carries corrections), and
114
+ // whether the revalidation pass is applied (when it carries one).
115
+ function viewText(view) {
116
+ if (!view) return ''
117
+ const parts = [view.bucket ? `${view.bucket} findings` : 'Live findings']
118
+ if (view.severityMode === 'original') parts.push('original analyzer severities')
119
+ else if (view.severityMode === 'corrected') parts.push('corrected severities')
120
+ if (view.revalidation === false) parts.push('code view — the revalidation pass is not applied')
121
+ else if (view.revalidation === true) {
122
+ // Which app view: the verdict standing in for the rows it re-rated
123
+ // (the default on screen, folded here as there), or the detailed one
124
+ // that lists them. A caller that doesn't track the detail says the
125
+ // layer is applied and no more.
126
+ if (view.revalidationDetail === true) parts.push('detailed app view — the revalidation pass is applied, with the rows it re-rated')
127
+ else if (view.revalidationDetail === false) parts.push('app view — the revalidation pass is applied, standing in for the rows it re-rated')
128
+ else parts.push('app view — the revalidation pass is applied')
129
+ }
130
+ return parts.join(' · ')
131
+ }
132
+
133
+ function includedText(counts) {
134
+ if (!counts) return ''
135
+ const included = Number(counts.included) || 0
136
+ const total = Number(counts.total) || 0
137
+ if (total === 0) return 'no findings'
138
+ if (included >= total) return `all ${plural(total, 'finding')}`
139
+ return `${included} of ${plural(total, 'finding')} (${total - included} filtered out)`
140
+ }
141
+
142
+ // The header list: what was exported, from where, when, and under which
143
+ // view and filters. Each line is written only when it has something to
144
+ // say, and the filter line says "none" outright, so its absence never
145
+ // has to be interpreted.
146
+ //
147
+ // `Source` names the products the loaded reports came from, `Analyzer`
148
+ // what produced the included findings. For one product they are the same
149
+ // word and the analyzer line is left out — but only when it would say
150
+ // exactly what Source says, the same products and no fewer: two products
151
+ // filtered down to one name the one, and a document holding the
152
+ // analyzer's own runs lists every analyzer. The reader takes a single
153
+ // analyzer named here as every finding's.
154
+ function headerList(doc, ctx, cases) {
155
+ const rows = []
156
+ const add = (label, value) => { if (value) rows.push(`- **${label}:** ${value}`) }
157
+ const reports = Array.isArray(doc.reports) ? doc.reports : []
158
+ const sources = [...new Set(reports.map((r) => SOURCE_LABELS[r?.source] ?? '').filter(Boolean))]
159
+ add('Source', sources.join(', '))
160
+ const names = reports.map((r) => r?.name).filter(Boolean)
161
+ add(names.length === 1 ? 'Report' : 'Reports', names.map((n) => code(n)).join(', '))
162
+ add('Workspace', typeof doc.workspace === 'string' ? doc.workspace.trim() : '')
163
+ if (ctx.repo) add('Repository', repoRef(ctx.repo))
164
+ const analyzers = [...new Set(cases.map(ctx.analyzerOf).filter(Boolean))]
165
+ const saidAlready = analyzers.length === sources.length && analyzers.every((a) => sources.includes(a))
166
+ if (analyzers.length > 0 && !saidAlready) add(analyzers.length === 1 ? 'Analyzer' : 'Analyzers', analyzers.join('; '))
167
+ if (doc.generatedAt) add('Exported', formatTimestamp(doc.generatedAt))
168
+ add('View', viewText(doc.view))
169
+ if (Array.isArray(doc.filters)) {
170
+ add('Filters', doc.filters.length > 0 ? doc.filters.map((f) => `${f.label}: ${f.value}`).join(' · ') : 'none')
171
+ }
172
+ add('Included', includedText(doc.counts))
173
+ return rows.join('\n')
174
+ }
175
+
176
+ // The groups bucketed by the severity their primary case displays under,
177
+ // in ladder order — an unknown tier last, as the report spelt it —
178
+ // numbered through the document, each with its heading and anchor.
179
+ function documentEntries(groups, ctx) {
180
+ const buckets = new Map()
181
+ for (const g of groups) {
182
+ const severity = displayedSeverity(g[0], ctx.severityMode) ?? 'informational'
183
+ if (!buckets.has(severity)) buckets.set(severity, [])
184
+ buckets.get(severity).push(g)
185
+ }
186
+ const order = [...SEVERITIES, ...[...buckets.keys()].filter((s) => !SEVERITIES.includes(s))]
187
+ const taken = new Set()
188
+ const entries = []
189
+ for (const severity of order) {
190
+ for (const group of buckets.get(severity) ?? []) {
191
+ const number = entries.length + 1
192
+ const title = findingHeading(group[0])
193
+ const headingText = `${number}. ${title}`
194
+ entries.push({ group, number, severity, title, headingText, slug: anchorSlug(headingText, taken) })
195
+ }
196
+ }
197
+ return entries
198
+ }
199
+
200
+ // Findings per tier, in entry order — which is ladder order, since the
201
+ // entries were bucketed that way.
202
+ function severityCounts(entries) {
203
+ const counts = new Map()
204
+ for (const e of entries) counts.set(e.severity, (counts.get(e.severity) ?? 0) + 1)
205
+ return counts
206
+ }
207
+
208
+ function annotationSummary(entries, ctx) {
209
+ const tally = { flagged: 0, marked: 0, commented: 0, fixed: 0 }
210
+ for (const { group } of entries) {
211
+ for (const f of group) {
212
+ const a = ctx.hooks.annotation(f)
213
+ if (!a) continue
214
+ if (a.flagged === true) tally.flagged++
215
+ if (a.color) tally.marked++
216
+ if (a.comment) tally.commented++
217
+ if (a.fix) tally.fixed++
218
+ }
219
+ }
220
+ const parts = []
221
+ if (tally.flagged) parts.push(`${tally.flagged} flagged`)
222
+ if (tally.marked) parts.push(`${tally.marked} colour-marked`)
223
+ if (tally.commented) parts.push(`${tally.commented} commented`)
224
+ if (tally.fixed) parts.push(`${tally.fixed} with a fix link`)
225
+ return parts.length > 0 ? `Annotations: ${parts.join(', ')}.` : ''
226
+ }
227
+
228
+ // One row per finding, linked to its section, so a reader sees the whole
229
+ // report on one screen and can jump. The confidence column appears only
230
+ // when something has a confidence.
231
+ function indexTable(entries) {
232
+ const withConfidence = entries.some(({ group }) => group.some((f) => f.confidence !== undefined && f.confidence !== null))
233
+ const headers = ['#', 'Severity', 'Finding', 'Location']
234
+ if (withConfidence) headers.push('Confidence')
235
+ const rows = entries.map((e) => {
236
+ const primary = e.group[0]
237
+ const cases = e.group.length > 1 ? ` (${plural(e.group.length, 'case')})` : ''
238
+ const loc = locationLabel(primary)
239
+ const row = [String(e.number), severityLabel(e.severity), link(cell(escapeBrackets(e.title)), `#${e.slug}`) + cases, loc ? cell(code(loc)) : '']
240
+ if (withConfidence) row.push(primary.confidence === undefined || primary.confidence === null ? '' : `${primary.confidence}/10`)
241
+ return row
242
+ })
243
+ return table(headers, rows, ['right'])
244
+ }
245
+
246
+ function summaryBlocks(entries, ctx) {
247
+ const blocks = [heading(2, 'Summary')]
248
+ if (entries.length === 0) return [...blocks, 'No findings are included.']
249
+ const rows = [...severityCounts(entries)].map(([s, n]) => [severityLabel(s), String(n)])
250
+ rows.push(['**Total**', `**${entries.length}**`])
251
+ blocks.push(table(['Severity', 'Findings'], rows, ['left', 'right']))
252
+ const notes = annotationSummary(entries, ctx)
253
+ if (notes) blocks.push(notes)
254
+ blocks.push(indexTable(entries))
255
+ return blocks
256
+ }
257
+
258
+ // One `## <Severity> (n)` section per tier present, the findings under
259
+ // it in the order they arrived — the caller's sort.
260
+ function severitySections(entries, ctx) {
261
+ const counts = severityCounts(entries)
262
+ const blocks = []
263
+ let current = null
264
+ for (const e of entries) {
265
+ if (e.severity !== current) {
266
+ current = e.severity
267
+ blocks.push(heading(2, `${severityLabel(current)} (${counts.get(current)})`))
268
+ }
269
+ blocks.push(groupSection(e.group, ctx, { headingText: e.headingText, depth: 3 }))
270
+ }
271
+ return blocks
272
+ }
273
+
274
+ export function writeMarkdown(doc = {}, hooks = {}) {
275
+ const h = withDefaults(hooks)
276
+ const groups = (Array.isArray(doc.groups) ? doc.groups : [])
277
+ .map((g) => (Array.isArray(g) ? g : [g]))
278
+ .map((g) => g.filter((f) => f && typeof f === 'object'))
279
+ .filter((g) => g.length > 0)
280
+ const cases = groups.flat()
281
+ const ctx = buildContext(doc, h, cases)
282
+ const entries = documentEntries(groups, ctx)
283
+ const blocks = [
284
+ DOCUMENT_MARKER,
285
+ heading(1, typeof doc.title === 'string' && doc.title.trim() ? doc.title.trim() : 'Findings'),
286
+ headerList(doc, ctx, cases),
287
+ ...summaryBlocks(entries, ctx),
288
+ ...severitySections(entries, ctx),
289
+ ]
290
+ return `${joinBlocks(blocks)}\n`
291
+ }
@@ -0,0 +1,16 @@
1
+ import { env } from 'node:process'
2
+
3
+ // One shared URL or per-mode URLs. Validate before opening either store so
4
+ // an ambiguous configuration cannot send application traffic and GC elsewhere.
5
+ export function databaseUrls({ combined = false } = {}) {
6
+ const shared = env['DATABASE_URL'] || null
7
+ const e2e = env['E2E_DATABASE_URL'] || null
8
+ const managed = env['MANAGED_DATABASE_URL'] || null
9
+ if (shared && (e2e || managed)) {
10
+ throw new Error('DATABASE_URL cannot be combined with E2E_DATABASE_URL or MANAGED_DATABASE_URL. Use the shared URL alone, or only the per-mode URLs.')
11
+ }
12
+ if (combined && Boolean(e2e) !== Boolean(managed)) {
13
+ throw new Error('Combined mode requires both E2E_DATABASE_URL and MANAGED_DATABASE_URL, DATABASE_URL alone, or no database URLs. Mixing Neon and SQLite is not supported.')
14
+ }
15
+ return { e2e: shared ?? e2e, managed: shared ?? managed }
16
+ }
@@ -0,0 +1,18 @@
1
+ // Register resources as they are acquired. Failed assembly rolls them back in
2
+ // reverse order; successful assembly transfers ownership to the returned app.
3
+ export async function initializeApp<T>(assemble: (rollback: AsyncDisposableStack) => T | Promise<T>): Promise<T> {
4
+ const rollback = new AsyncDisposableStack()
5
+ try {
6
+ const app = await assemble(rollback)
7
+ rollback.move()
8
+ return app
9
+ } catch (error) {
10
+ try { await rollback.disposeAsync() }
11
+ catch (cleanupError) {
12
+ // AsyncDisposableStack runs every cleanup even if one fails. Preserve
13
+ // both failures so cleanup errors cannot hide the initialization error.
14
+ throw new SuppressedError(cleanupError, error, 'App initialization and cleanup failed')
15
+ }
16
+ throw error
17
+ }
18
+ }