@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,375 @@
1
+ // The readers for one case of the DeepView markdown document: the fact
2
+ // list under a finding's heading, the sections under that, the evidence
3
+ // list, the description they add up to. Which section is a finding and
4
+ // which `####` is a case belongs to parse-deepview-md.js; this module
5
+ // knows how write-md-finding.js spelt each value.
6
+ //
7
+ // Every reader is the inverse of a writer — `readLocation` of
8
+ // locationText, `readSeverity` of severityText, and so on — and
9
+ // `narrativeSplit` undoes the one thing the writer folds: a `**Label:**`
10
+ // paragraph and a field of the same name both became a section, and
11
+ // only their position says which was which.
12
+
13
+ import { REVALIDATE_KINDS, firstLine } from './finding.js'
14
+ import { SEVERITY_LABELS, SOURCE_LABELS } from './labels.js'
15
+ import { FILE_LINE_RE, fenceRanges, findMdLink, inFence, isCommitHash } from './md-structure.js'
16
+ import { isHttpUrl, unescapeHeadings } from './md-text.js'
17
+
18
+ // label (case-folded) → key, for the words the writer spells the app's
19
+ // enumerations with (labels.js).
20
+ const SEVERITY_KEYS = new Map(Object.entries(SEVERITY_LABELS).map(([k, v]) => [v.toLowerCase(), k]))
21
+ const SOURCE_KEYS = new Map(Object.entries(SOURCE_LABELS).map(([k, v]) => [v.toLowerCase(), k]))
22
+ const REVALIDATE_SET = new Set(REVALIDATE_KINDS)
23
+
24
+ // ── Inline forms ─────────────────────────────────────────────────────
25
+
26
+ // The content of the first code span in `s`, or null when it has none.
27
+ // The fence is as many backticks as the writer needed to quote the
28
+ // content (md-text.js code) — always more than any run inside it, so
29
+ // the first closing run of that length is the fence — and a space of
30
+ // padding on each side when the content itself starts or ends on one.
31
+ function codeSpan(s) {
32
+ const m = /(`+)(.+?)\1(?!`)/u.exec(String(s ?? ''))
33
+ if (!m) return null
34
+ const inner = m[2]
35
+ if (inner.length > 2 && inner.startsWith(' ') && inner.endsWith(' ')) {
36
+ const unpadded = inner.slice(1, -1)
37
+ if (unpadded.startsWith('`') || unpadded.endsWith('`')) return unpadded
38
+ }
39
+ return inner
40
+ }
41
+
42
+ // A markdown link at the START of `s` — `[label](url)`, the URL in
43
+ // angle brackets when the writer had to (md-text.js link). The reading
44
+ // itself is md-structure.js's, which is the whole library's; a link
45
+ // found further along belongs to something else in the value, so only
46
+ // one at index 0 answers here.
47
+ export function readLink(s) {
48
+ const link = findMdLink(String(s ?? ''))
49
+ return link?.index === 0 ? { label: link.label, url: link.url } : null
50
+ }
51
+
52
+ // A bare `<url>` autolink (md-text.js autolink), or null.
53
+ function autolinkUrl(s) {
54
+ const m = /^<(https?:[^>\s]*)>$/u.exec(s.trim())
55
+ return m ? m[1] : null
56
+ }
57
+
58
+ // `file:line` back into its two fields — the line a number or a
59
+ // `10-20` range, `?` when the label carried none (finding.js
60
+ // locationLabel).
61
+ function fileLine(label) {
62
+ const m = FILE_LINE_RE.exec(label)
63
+ return m ? { file: m[1], line: m[2] } : { file: label, line: '?' }
64
+ }
65
+
66
+ // `exportName.methodName` back into the two names, or the one name
67
+ // (finding.js findingDisplayName). A name with no dot is an export.
68
+ function exportNames(name) {
69
+ const dot = name.indexOf('.')
70
+ return dot > 0 ? { exportName: name.slice(0, dot), methodName: name.slice(dot + 1) } : { exportName: name }
71
+ }
72
+
73
+ // A tier's key from the word the writer spelt it with; a tier the
74
+ // ladder doesn't know was printed as itself and comes back as itself.
75
+ export function tierOf(label) {
76
+ const t = String(label ?? '').trim()
77
+ return SEVERITY_KEYS.get(t.toLowerCase()) ?? t
78
+ }
79
+
80
+ // ── The facts ────────────────────────────────────────────────────────
81
+
82
+ // `[`src/a.js:7`](url) · `Foo.bar`` — the reference, linked or not,
83
+ // then the export it sits in (write-md-finding.js locationText). The
84
+ // link is the report's own location link (finding.js: `location`),
85
+ // which the card links to in preference to anything reconstructed.
86
+ function readLocation(value) {
87
+ const out = {}
88
+ let s = value.trim()
89
+ const named = / · (`+)(.+?)\1$/u.exec(s)
90
+ if (named) {
91
+ Object.assign(out, exportNames(codeSpan(named[0])))
92
+ s = s.slice(0, named.index)
93
+ }
94
+ const link = readLink(s)
95
+ const label = codeSpan(link ? link.label : s)
96
+ if (label !== null) Object.assign(out, fileLine(label))
97
+ if (link && isHttpUrl(link.url)) out.location = link.url
98
+ return out
99
+ }
100
+
101
+ const CRITICAL_FLAG = ' · flagged critical by the analyzer'
102
+ const VARIES = ' (varies across reports — '
103
+
104
+ // `High — corrected from Medium (varies across reports — …) · flagged
105
+ // critical by the analyzer` back into severity / correctedSeverity /
106
+ // critical (write-md-finding.js severityText). Under the original lens
107
+ // the line reads `Medium — corrected to High`; either way it says which
108
+ // is which. The per-report variants are the viewer's own bookkeeping
109
+ // of a workspace merge, not a finding's field.
110
+ function readSeverity(value) {
111
+ const out = {}
112
+ let s = value.trim()
113
+ if (s.endsWith(CRITICAL_FLAG)) {
114
+ out.critical = true
115
+ s = s.slice(0, -CRITICAL_FLAG.length)
116
+ }
117
+ const varies = s.indexOf(VARIES)
118
+ if (varies !== -1) s = s.slice(0, varies)
119
+ const m = /^(.*?) — corrected (from|to) (.*)$/u.exec(s)
120
+ if (!m) {
121
+ out.severity = tierOf(s)
122
+ } else if (m[2] === 'from') {
123
+ out.severity = tierOf(m[3])
124
+ out.correctedSeverity = tierOf(m[1])
125
+ } else {
126
+ out.severity = tierOf(m[1])
127
+ out.correctedSeverity = tierOf(m[3])
128
+ }
129
+ return out
130
+ }
131
+
132
+ // The effort ladder and import modes a run is described with — closed
133
+ // vocabularies, which is what lets a run's line be read back by
134
+ // position: `<type> · [revalidate] · <model> · <effort> · <mode>`, an
135
+ // absent part elided (finding.js runMetaLine).
136
+ const EFFORTS = new Set(['max', 'xhigh', 'high', 'medium', 'low', 'minimal'])
137
+ const IMPORT_MODES = new Set(['list', 'isolate'])
138
+
139
+ // A model's pretty name carries a version — `opus 5`, `gpt 5.5` — or at
140
+ // least a family, where a mode (`security`) carries neither. Consulted
141
+ // only when the line leaves one free word, whose slot is ambiguous.
142
+ function looksLikeModel(word) {
143
+ return /\d/u.test(word) || /^(?:opus|sonnet|haiku|gpt|gemini|fable|mythos|llama|mistral)\b/iu.test(word)
144
+ }
145
+
146
+ // What produced a finding (write-md-finding.js analyzerText): a
147
+ // product's name, back into its `source` key — or the run's meta line,
148
+ // back into the run's fields.
149
+ export function readAnalyzer(value) {
150
+ const s = value.trim()
151
+ const source = SOURCE_KEYS.get(s.toLowerCase())
152
+ if (source) return { source }
153
+ const run = {}
154
+ const free = []
155
+ for (const word of s.split(' · ').map((w) => w.trim()).filter(Boolean)) {
156
+ if (word === 'revalidate' && !run.revalidate) run.revalidate = 'revalidation'
157
+ else if (IMPORT_MODES.has(word) && !run.exportsMode) run.exportsMode = word
158
+ else if (EFFORTS.has(word) && !run.effort) run.effort = word
159
+ else free.push(word)
160
+ }
161
+ if (free.length > 1) [run.type, run.model] = free
162
+ else if (free.length === 1) run[looksLikeModel(free[0]) ? 'model' : 'type'] = free[0]
163
+ return { run }
164
+ }
165
+
166
+ // A repository reference (write-md-finding.js repoRef): the slug of a
167
+ // github.com link, a bare URL, or the text as it was.
168
+ export function readRepository(value) {
169
+ const s = value.trim()
170
+ const link = readLink(s)
171
+ if (link) return link.label.trim()
172
+ return autolinkUrl(s) ?? s
173
+ }
174
+
175
+ // The introducing commit (commitText): linked, the short hash in the
176
+ // label and the whole one at the end of the URL; unlinked, the hash in
177
+ // a code span.
178
+ function readCommit(value) {
179
+ const link = readLink(value.trim())
180
+ if (link) {
181
+ const tail = link.url.split('/').at(-1) ?? ''
182
+ if (isCommitHash(tail)) return tail
183
+ return codeSpan(link.label) ?? tail
184
+ }
185
+ return codeSpan(value) ?? value.trim()
186
+ }
187
+
188
+ // `name@version` in a code span, back into the npm package a
189
+ // dependency finding sits in.
190
+ function readPackage(value) {
191
+ const s = codeSpan(value) ?? value.trim()
192
+ const at = s.lastIndexOf('@')
193
+ return { npm: at > 0 ? { name: s.slice(0, at), version: s.slice(at + 1) } : { name: s } }
194
+ }
195
+
196
+ // The revalidation stamp (metaList): the pass's own row is named in
197
+ // words, a verdict by its kind.
198
+ function readRevalidation(value) {
199
+ const s = value.trim().toLowerCase()
200
+ if (s === 'the revalidation pass itself') return 'revalidation'
201
+ return REVALIDATE_SET.has(s) ? s : undefined
202
+ }
203
+
204
+ // One fact back onto the finding, keyed by the label the writer gave it.
205
+ // Not here: `Analyzer`, which the document settles for every finding at
206
+ // once; `Triage` / `Fix` and the `Comment` section, the reader's
207
+ // annotations, which live in the viewer's triage store and follow the
208
+ // id; and `Report`, which names the file a case came from — now this one.
209
+ const FACT_READERS = new Map([
210
+ ['location', (f, v) => Object.assign(f, readLocation(v))],
211
+ ['severity', (f, v) => Object.assign(f, readSeverity(v))],
212
+ ['confidence', (f, v) => { const m = /^(\d+(?:\.\d+)?)\/10$/u.exec(v); if (m) f.confidence = Number(m[1]) }],
213
+ ['revalidation', (f, v) => { const kind = readRevalidation(v); if (kind) f.revalidate = kind }],
214
+ ['revalidated by', (f, v) => { const s = v.trim(); if (s) f.revalidateSource = SOURCE_KEYS.get(s.toLowerCase()) ?? s }],
215
+ ['repository', (f, v) => { f.repo = { github: readRepository(v) } }],
216
+ ['introduced in', (f, v) => { f.commitHash = readCommit(v) }],
217
+ ['package', (f, v) => { f.package = readPackage(v) }],
218
+ ['priority', (f, v) => { f.priority = /^-?\d+(?:\.\d+)?$/u.test(v) ? Number(v) : v }],
219
+ ['found while analyzing', (f, v) => { f.discoveredIn = codeSpan(v) ?? v }],
220
+ ['detailed report', (f, v) => { f.reportPath = codeSpan(v) ?? v }],
221
+ ['commit audited', (f, v) => { f.auditedCommit = codeSpan(v) ?? v }],
222
+ ['id', (f, v) => { f.id = codeSpan(v) ?? v }],
223
+ ...[
224
+ ['category', 'category'], ['status', 'status'], ['branch', 'branch'],
225
+ ['date created', 'dateCreated'], ['detected at', 'detectedAt'], ['committed at', 'committedAt'],
226
+ ['poc status', 'pocStatus'], ['variant of', 'parent'], ['slug', 'slug'],
227
+ ].map(([label, field]) => [label, (f, v) => { f[field] = v }]),
228
+ ])
229
+
230
+ export function applyFact(f, label, value) {
231
+ const read = FACT_READERS.get(label.trim().toLowerCase())
232
+ if (read) read(f, value.trim())
233
+ }
234
+
235
+ // ── The shape under a heading ────────────────────────────────────────
236
+
237
+ const FACT_RE = /^- \*\*([^*\n]+?):\*\* ?(.*)$/u
238
+
239
+ // The fact list at the top of a case: consecutive `- **Label:** value`
240
+ // lines. A paragraph BEFORE the list is the case's own title, written
241
+ // where a case is named differently from its group — but only when a
242
+ // list follows, since a case with no facts keeps its opening paragraph
243
+ // as prose. Prose comes back with the writer's heading escape off, here
244
+ // and wherever readProse is used, so `\## Internal detail` is the
245
+ // `## Internal detail` the description held.
246
+ export function splitFacts(body) {
247
+ const lines = body.split('\n')
248
+ let i = 0
249
+ const skipBlank = () => { while (i < lines.length && !lines[i].trim()) i++ }
250
+ const readFacts = () => {
251
+ const facts = []
252
+ while (i < lines.length) {
253
+ const m = FACT_RE.exec(lines[i])
254
+ if (!m) break
255
+ facts.push([m[1].trim(), m[2].trim()])
256
+ i++
257
+ }
258
+ return facts
259
+ }
260
+ skipBlank()
261
+ let facts = readFacts()
262
+ if (facts.length > 0) return { title: '', facts, rest: lines.slice(i).join('\n') }
263
+ const para = []
264
+ while (i < lines.length && lines[i].trim()) para.push(lines[i++])
265
+ skipBlank()
266
+ facts = readFacts()
267
+ if (facts.length === 0) return { title: '', facts, rest: body }
268
+ return { title: readProse(para.join('\n').trim()), facts, rest: lines.slice(i).join('\n') }
269
+ }
270
+
271
+ // A run of prose as the description held it: the writer's heading
272
+ // escape off, when the document's writer put one on.
273
+ export function readProse(text) {
274
+ return unescapeHeadings(text)
275
+ }
276
+
277
+ // A case's sections at `depth` (4 under a finding's heading, 5 under a
278
+ // case's): the lead before the first heading and `[{ label, body }]`
279
+ // after it — outside fences only, so a `#### ` line in a snippet stays
280
+ // in the snippet.
281
+ export function splitSections(text, depth) {
282
+ const re = new RegExp(`^#{${depth}} +(.*)$`, 'gmu')
283
+ const ranges = fenceRanges(text)
284
+ const marks = [...text.matchAll(re)].filter((m) => !inFence(ranges, m.index))
285
+ const lead = text.slice(0, marks[0]?.index ?? text.length).trim()
286
+ const sections = marks.map((m, i) => ({
287
+ label: m[1].trim(),
288
+ body: text.slice(m.index + m[0].length, marks[i + 1]?.index).trim(),
289
+ }))
290
+ return { lead, sections }
291
+ }
292
+
293
+ const ITEM_RE = /^(\d+)\. (.*)$/u
294
+
295
+ // The evidence list (write-md-finding.js evidenceList): a loose
296
+ // numbered list, each item's note on the lines under it, indented to
297
+ // the item's text. Back into rows of `{ file, line, url, text }` — the
298
+ // note under the name parse-md.js gives it, whatever a native dump
299
+ // called it (finding.js evidenceNote reads both).
300
+ export function readEvidence(text) {
301
+ const items = []
302
+ const ranges = fenceRanges(text)
303
+ let pos = 0
304
+ for (const line of text.split('\n')) {
305
+ const m = inFence(ranges, pos) ? null : ITEM_RE.exec(line)
306
+ pos += line.length + 1
307
+ if (m) items.push({ ref: m[2].trim(), indent: m[1].length + 2, note: [] })
308
+ else if (items.length > 0) items.at(-1).note.push(line)
309
+ }
310
+ return items.map((item) => evidenceRow(item))
311
+ }
312
+
313
+ function evidenceRow({ ref, indent, note }) {
314
+ const row = {}
315
+ const link = readLink(ref)
316
+ const auto = autolinkUrl(ref)
317
+ const label = auto === null ? codeSpan(link ? link.label : ref) : null
318
+ if (label !== null) Object.assign(row, fileLine(label))
319
+ const url = link ? link.url : auto
320
+ if (isHttpUrl(url)) row.url = url
321
+ const text = readProse(note.map((l) => l.slice(Math.min(indent, /^ */u.exec(l)[0].length))).join('\n').trim())
322
+ if (text) row.text = text
323
+ return row
324
+ }
325
+
326
+ // ── The narrative ────────────────────────────────────────────────────
327
+
328
+ // The narrative fields the writer gives their own sections, in the
329
+ // order it writes them (write-md-finding.js NARRATIVE) — AFTER the
330
+ // sections the description's own `**Label:**` paragraphs became.
331
+ const NARRATIVE = new Map([
332
+ ['impact', 'impact'], ['reproduction', 'reproduction'], ['recommendation', 'recommendation'],
333
+ ['confidence reasoning', 'confidenceReason'], ['revalidation verdict', 'revalidateVerdict'],
334
+ ['revalidation recommendation', 'revalidateRecommendation'],
335
+ ])
336
+ const NARRATIVE_ORDER = [...NARRATIVE.keys()]
337
+
338
+ // Which sections were fields and which the description's own. The writer
339
+ // prints the description's labelled paragraphs first, whatever they are
340
+ // called, then the fields in NARRATIVE order — so the fields are the
341
+ // longest run of narrative labels in that order at the END, and
342
+ // everything before goes back into the description as the `**Label:**`
343
+ // paragraph it was. A report that wrote `**Impact:**` into its prose
344
+ // comes back with an `impact` field, as a native dump would have; a
345
+ // `**Root Cause:**` paragraph and any `**Impact:**` before it stay
346
+ // paragraphs, so a second export reads as the first did.
347
+ export function narrativeSplit(sections) {
348
+ let start = sections.length
349
+ let last = Infinity
350
+ for (let i = sections.length - 1; i >= 0; i--) {
351
+ const rank = NARRATIVE_ORDER.indexOf(sections[i].label.toLowerCase())
352
+ if (rank === -1 || rank >= last) break
353
+ last = rank
354
+ start = i
355
+ }
356
+ return {
357
+ paragraphs: sections.slice(0, start),
358
+ fields: sections.slice(start).map((s) => [NARRATIVE.get(s.label.toLowerCase()), s.body]),
359
+ }
360
+ }
361
+
362
+ // The description back from its parts: the heading's text as the first
363
+ // line — unless the lead already opens with it, which is how a name too
364
+ // long for a heading travels — then the lead, then the description's own
365
+ // labelled paragraphs, as every parser writes them. No heading text
366
+ // leaves the lead to speak for itself.
367
+ export function buildDescription(title, lead, paragraphs) {
368
+ const parts = []
369
+ const first = firstLine(lead)
370
+ const cut = title.endsWith('…') ? title.slice(0, -1).trimEnd() : ''
371
+ if (!title || first === title || (cut && first.startsWith(cut))) parts.push(lead)
372
+ else parts.push(title, lead)
373
+ for (const { label, body } of paragraphs) parts.push(body ? `**${label}:** ${body}` : `**${label}:**`)
374
+ return parts.filter(Boolean).join('\n\n')
375
+ }
@@ -0,0 +1,185 @@
1
+ // The reader for the document this library's own writer produces
2
+ // (write-md.js, what the viewer's Download button saves). An export
3
+ // reads back in through the same door as every other format, each
4
+ // finding with the id it had — so its triage still applies — and with
5
+ // its facts in the fields the parser that first read it used: a Claude
6
+ // Security finding comes back as one, a native dump's with its run.
7
+ //
8
+ // The document (write-md.js for the whole shape):
9
+ //
10
+ // <!-- DeepView findings export --> ← the guard
11
+ // # <title>
12
+ // - **Source:** Claude Security the header list
13
+ // - **Repository:** [o/r](…) / **Analyzer:** …
14
+ // ## Summary tables — skipped
15
+ // ## High (2) a tier's section
16
+ // ### 1. <finding> an entry
17
+ // - **Location:** … / **Severity:** … / **ID:** … the facts
18
+ // <description> the lead
19
+ // #### Evidence / #### Impact / … the sections
20
+ //
21
+ // and a finding reported several times is an entry of cases:
22
+ //
23
+ // ### 2. <finding>
24
+ // 2 cases of this finding — reported in `a.json`, `b.json`.
25
+ // #### Case 1 of 2 — `src/a.js:7`
26
+ // - **Location:** … / ##### Impact …
27
+ //
28
+ // Out comes the JSON shape the rest of the chain emits:
29
+ //
30
+ // { type, source?, model?, effort?, exportsMode?, repo?, findings }
31
+ //
32
+ // with `groups` in place of `findings` when an entry has several cases —
33
+ // a pre-deduplicated dump's shape. The producer and the run travel as a
34
+ // native dump carries them: report-level where every finding shares
35
+ // them, per finding where they vary. A document mixing products with the
36
+ // analyzer's own runs stamps `source` on each product's findings, which
37
+ // the viewer reads as that finding's analyzer.
38
+ //
39
+ // Not read back: what the reader wrote on a finding (Triage, Fix,
40
+ // Comment), which lives in the viewer's triage store and follows the id;
41
+ // and the header's account of the export (view, filters, counts), since
42
+ // the findings on the page ARE the selection. Prose comes back with the
43
+ // writer's heading escape off (md-text.js unescapeHeadings).
44
+ //
45
+ // The marker line is the whole guard: without it the text is not this
46
+ // document and returns null, so the chain moves on. The guard reads the
47
+ // phrase and not what follows, so a later document that says more there
48
+ // is still recognised and read as well as this reader can. A document
49
+ // holding NO finding is still the report its header describes — an
50
+ // export is a SELECTION and a selection can be empty, which the header
51
+ // says outright ("Included: no findings") — so an empty export of a
52
+ // Claude Security report reads back as
53
+ // `{ type: 'security', source: 'claude-security', findings: [] }`
54
+ // rather than as a file no format recognises.
55
+
56
+ import { locationLabel } from './finding.js'
57
+ import { H2_RE, H3_RE, H4_RE, normalizeNewlines, splitByHeading, splitLeading } from './md-structure.js'
58
+ import { applyFact, buildDescription, narrativeSplit, readAnalyzer, readEvidence, readProse, readRepository, splitFacts, splitSections, tierOf } from './parse-deepview-fields.js'
59
+
60
+ const MARKER_RE = /^<!--\s*DeepView findings export\b[^>]*-->/u
61
+ const CASE_RE = /^Case \d+ of \d+(?:\s|$)/u
62
+ const HEADER_FACT_RE = /^- \*\*([^*\n]+?):\*\* ?(.*)$/gmu
63
+
64
+ // The sections under a case that are not the description's own nor a
65
+ // narrative field: the evidence rows, the correction's reason, and the
66
+ // reader's comment.
67
+ const OWN_SECTIONS = new Set(['evidence', 'severity correction', 'comment'])
68
+
69
+ export function parseDeepviewMarkdown(content) {
70
+ const text = normalizeNewlines(content).trim()
71
+ if (!MARKER_RE.test(text)) return null
72
+ const { head, subs } = splitLeading(text, H2_RE)
73
+ const entries = []
74
+ for (const { heading, body } of subs) {
75
+ if (heading.trim().toLowerCase() === 'summary') continue
76
+ const tier = sectionTier(heading)
77
+ for (const block of splitByHeading(body, H3_RE)) entries.push(readEntry(block, tier))
78
+ }
79
+ return assemble(readHeader(head), entries)
80
+ }
81
+
82
+ // `High (2)` → 'high'. A section named after no tier passes its name
83
+ // through as printed; the fact line under each finding is authoritative.
84
+ function sectionTier(heading) {
85
+ const m = /^(.*?)\s*\(\d+\)\s*$/u.exec(heading.trim())
86
+ return tierOf(m ? m[1] : heading)
87
+ }
88
+
89
+ // The header list: the products the reports came from, the analyzers
90
+ // named, the repository. The rest describes the export, not the findings.
91
+ function readHeader(head) {
92
+ const facts = new Map()
93
+ for (const m of head.matchAll(HEADER_FACT_RE)) {
94
+ const key = m[1].trim().toLowerCase()
95
+ if (!facts.has(key)) facts.set(key, m[2].trim())
96
+ }
97
+ return {
98
+ sources: (facts.get('source') ?? '').split(',').map((s) => readAnalyzer(s).source).filter(Boolean),
99
+ analyzers: (facts.get('analyzer') ?? facts.get('analyzers') ?? '').split(';').map((s) => s.trim()).filter(Boolean),
100
+ repo: facts.has('repository') ? readRepository(facts.get('repository')) : '',
101
+ }
102
+ }
103
+
104
+ // One `### N. <finding>` block: a finding, or one with a case per
105
+ // `#### Case i of n` under it.
106
+ function readEntry({ heading, body }, tier) {
107
+ const title = heading.trim().replace(/^\d+\.\s+/u, '')
108
+ const cases = splitLeading(body, H4_RE).subs.filter((s) => CASE_RE.test(s.heading.trim()))
109
+ if (cases.length === 0) return [readCase(body, 4, title, tier)]
110
+ return cases.map((s) => readCase(s.body, 5, title, tier))
111
+ }
112
+
113
+ // One case's text into a finding: the facts, the description its lead
114
+ // and sections add up to, the evidence, the narrative fields. The
115
+ // Analyzer fact rides beside it for `assemble` to settle report-level.
116
+ function readCase(body, depth, entryTitle, tier) {
117
+ const { title, facts, rest } = splitFacts(body)
118
+ const { lead, sections } = splitSections(rest, depth)
119
+ const f = { file: 'unknown', line: '?' }
120
+ let analyzer = null
121
+ for (const [label, value] of facts) {
122
+ if (label.trim().toLowerCase() === 'analyzer') analyzer = readAnalyzer(value)
123
+ else applyFact(f, label, value)
124
+ }
125
+ if (!f.severity) f.severity = tier || 'medium'
126
+ // A finding the writer could only head by its location, or by nothing
127
+ // at all, had no name; its description is what the lead says.
128
+ const name = title || entryTitle
129
+ const named = name !== 'Untitled finding' && name !== locationLabel(f)
130
+ const own = sections.filter((s) => !OWN_SECTIONS.has(s.label.toLowerCase()))
131
+ .map((s) => ({ label: s.label, body: readProse(s.body) }))
132
+ const { paragraphs, fields } = narrativeSplit(own)
133
+ f.description = buildDescription(named ? name : '', readProse(lead), paragraphs)
134
+ const evidence = sections.filter((s) => s.label.toLowerCase() === 'evidence').flatMap((s) => readEvidence(s.body))
135
+ if (evidence.length > 0) f.evidence = evidence
136
+ for (const [field, value] of fields) f[field] = value
137
+ const reason = sections.find((s) => s.label.toLowerCase() === 'severity correction')
138
+ if (reason?.body) f.correctedSeverityReason = readProse(reason.body)
139
+ return { finding: f, analyzer }
140
+ }
141
+
142
+ // The producer and the run for the whole report: from the Analyzer facts
143
+ // where the writer named one per finding — a product every case shares
144
+ // goes report-level, a run to its finding, a product only some cases
145
+ // came from to those cases — else from the header's one analyzer or its
146
+ // Source line. The pass marker is per-finding, never report-level.
147
+ function settleAnalyzers(header, cases) {
148
+ const out = { source: null, run: {} }
149
+ if (cases.some((c) => c.analyzer !== null)) {
150
+ const sources = new Set(cases.map((c) => c.analyzer?.source ?? null))
151
+ if (sources.size === 1 && !sources.has(null)) [out.source] = sources
152
+ for (const c of cases) {
153
+ if (c.analyzer?.run) Object.assign(c.finding, c.analyzer.run)
154
+ else if (c.analyzer?.source && !out.source) c.finding.source = c.analyzer.source
155
+ }
156
+ return out
157
+ }
158
+ const one = header.analyzers.length === 1 ? readAnalyzer(header.analyzers[0]) : null
159
+ if (one?.source) out.source = one.source
160
+ else if (one?.run) out.run = one.run
161
+ else if (header.analyzers.length === 0 && header.sources.length === 1) [out.source] = header.sources
162
+ return out
163
+ }
164
+
165
+ // The report: what the header and findings agree on at the top, then the
166
+ // findings, grouped only where an entry had cases.
167
+ function assemble(header, entries) {
168
+ const cases = entries.flat()
169
+ const { source, run } = settleAnalyzers(header, cases)
170
+ const data = {}
171
+ // The report's `type` is the run's — or, where each finding names its
172
+ // own, the one mode they all ran in, as a deduplicated dump keeps the
173
+ // mode in its header while its findings carry their models. A
174
+ // product's report is a security report, as its own parser says.
175
+ const modes = new Set(cases.map((c) => c.finding.type).filter(Boolean))
176
+ const type = run.type ?? (modes.size === 1 ? [...modes][0] : null) ?? (source ? 'security' : null)
177
+ if (type) data.type = type
178
+ if (source) data.source = source
179
+ for (const key of ['model', 'effort', 'exportsMode']) if (run[key]) data[key] = run[key]
180
+ if (header.repo) data.repo = { github: header.repo }
181
+ const groups = entries.map((entry) => entry.map((c) => c.finding))
182
+ if (groups.every((g) => g.length === 1)) data.findings = groups.flat()
183
+ else data.groups = groups
184
+ return data
185
+ }