Public: anyone can read this; the crew can change it.
A .sarif file in a repository opens as its findings: rule, level, file and line, and message per row #11034
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11029-a-sarif-file-in-a-repository-opens-as-it → main 6 commits fork 4 threads · 4 unresolved
docs/design/areas/render.md +4
| @@ -31,3 +31,7 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 31 | 31 | `src/web/render/gpx.ts` draws the route as one inline SVG in `.gpx-view`: no fixed width or height, `max-width: 100%` and `height: auto` (tokens only, `currentColor` stroke, no accent), so it fits a 360px phone. Its facts (track, distance, climb, points) are the existing `.status-rows` / `.status-row`, with no box. How it works: `docs/features/gpx.md`. |
| 32 | 32 | |
| 33 | 33 | `src/web/render/geojson.ts` draws a GeoJSON file as one inline SVG in `.gj-view` (same rules as `.gpx-view`: no fixed size, `max-width: 100%`, `currentColor`), and the first 100 features' properties as a plain table in `.gj-table`, which scrolls sideways inside its own box on a phone. |
| 34 | ||
| 35 | ## SARIF findings (.sarif) | |
| 36 | ||
| 37 | `src/web/render/sarif.ts` draws one existing `.table` (`data-dense`) per run under an `h3` with the tool's name, with the level counts as a plain line above. `.sarif-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-sarif.md`. |
docs/features/file-sarif.md added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | # A .sarif file opens as its findings | |
| 2 | ||
| 3 | Security scanners (CodeQL, Semgrep, eslint) write SARIF. A `.sarif` file in a repository opens as a list of findings instead of raw JSON; Source is one tap away. | |
| 4 | ||
| 5 | - **Counts** at the top: errors, warnings, notes, none. A result with no `level` counts as a warning (SARIF §3.27.10). | |
| 6 | - **One table per run**, headed by the tool's name (`tool.driver.name`). One row per result: level, rule id, `file:line`, message. | |
| 7 | - **Location** links to that line (`#L12`) in the same repository at the same ref when the URI is a plain relative path. The page checks the file exists at the ref (the ref's file list); a missing file, or a listing too long to read in full, shows plain text. Absolute, `file:` or `..` URIs are shown as text. | |
| 8 | - **Cap:** at most 1,000 results are drawn across all runs; the page says how many were left out. Files over 5 MB keep Source. | |
| 9 | - **Fallback:** invalid JSON, or no `runs` array, shows Source with the reason. | |
| 10 | - Every string is escaped. No script, no new CSS (`.sarif-view` is a hook with no rule). | |
| 11 | ||
| 12 | **Terminal:** opening a `.sarif` in the workspace shows a table of level, rule, location, message and tool (`sarifTable` in `src/tui/screens/workspace.ts`, over the same `parseSarif`), "N findings" in the status line; `s` flips to the source. Locations are text there. | |
| 13 | ||
| 14 | Code: `src/web/render/sarif.ts`, `src/tui/screens/workspace.ts`. Tests: `test/render-sarif.test.ts`, `test/tui-workspace.test.ts`. |
src/git/path_listing.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * Every path at a ref, kept by the tree it names. A tree id is immutable, so | |
| 3 | * a kept listing is never stale: a branch that moves names a new tree and | |
| 4 | * misses. The tree is resolved on the repo's long-lived `cat-file | |
| 5 | * --batch-check` (no spawn); only a miss pays `ls-tree -r`. | |
| 6 | * | |
| 7 | * A listing is up to 50,000 paths (~2 MB), so few are kept: the hot case is | |
| 8 | * the same ref asked again, by a .sarif page reloaded or the file finder. | |
| 9 | */ | |
| 10 | ||
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | import type { GitFailure } from "./env.ts"; | |
| 13 | import { isSafeRef } from "./guard.ts"; | |
| 14 | import { listPaths, readerFor, type PathListing } from "./read.ts"; | |
| 15 | ||
| 16 | const MAX_KEPT = 8; | |
| 17 | const kept = new Map<string, PathListing>(); | |
| 18 | ||
| 19 | export async function pathsAtTree(dir: string, ref: string): Promise<Result<PathListing, GitFailure | "missing">> { | |
| 20 | if (!isSafeRef(ref)) return err("missing"); | |
| 21 | const tree = await readerFor(dir).stat(`${ref}^{tree}`); | |
| 22 | if (!tree.ok || tree.value.type !== "tree") return err("missing"); | |
| 23 | const key = `${dir}\0${tree.value.oid}`; | |
| 24 | const hit = kept.get(key); | |
| 25 | if (hit !== undefined) { | |
| 26 | kept.delete(key); | |
| 27 | kept.set(key, hit); | |
| 28 | return ok(hit); | |
| 29 | } | |
| 30 | const listed = await listPaths(dir, tree.value.oid); | |
| 31 | if (!listed.ok) return listed; | |
| 32 | kept.set(key, listed.value); | |
| 33 | if (kept.size > MAX_KEPT) kept.delete(kept.keys().next().value!); | |
| 34 | return listed; | |
| 35 | } | |
| 36 | ||
| 37 | /** How many listings are kept now; for tests. */ | |
| 38 | export function keptListings(): number { | |
| 39 | return kept.size; | |
| 40 | } |
src/tui/screens/workspace.ts +29 −4
| @@ -59,6 +59,7 @@ import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; | ||
| 59 | 59 | import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts"; |
| 60 | 60 | import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts"; |
| 61 | 61 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 62 | import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts"; | |
| 62 | 63 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 63 | 64 | import { fileSize } from "../../web/ui.ts"; |
| 64 | 65 | import { parseEml } from "../../web/render/eml-parse.ts"; |
| @@ -400,6 +401,22 @@ function gpxTable(source: string, path: string): Table | null { | ||
| 400 | 401 | return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 }; |
| 401 | 402 | } |
| 402 | 403 | |
| 404 | /** `.sarif`: a scanner's findings, drawn as a table of level, rule, location and message. */ | |
| 405 | function isSarifPath(path: string): boolean { | |
| 406 | return /\.sarif$/i.test(path); | |
| 407 | } | |
| 408 | ||
| 409 | /** The findings of a SARIF file as a table, tool by tool; the reason, as the page words it, when it is not SARIF. Shares the web's parser. */ | |
| 410 | function sarifTable(source: string): Table | string { | |
| 411 | const parsed = parseSarif(source); | |
| 412 | if ("error" in parsed) return `not drawn as findings: ${parsed.error}`; | |
| 413 | const all = parsed.runs.flatMap((r) => | |
| 414 | r.results.map((f) => [f.level, f.rule, f.file === "" ? "-" : f.line > 0 ? `${f.file}:${f.line}` : f.file, f.message.replace(/\s+/g, " "), r.tool]), | |
| 415 | ); | |
| 416 | const rows = all.slice(0, RESULT_CAP); | |
| 417 | return { header: ["Level", "Rule", "Location", "Message", "Tool"], rows, more: all.length - rows.length, delimiter: ",", columns: 5 }; | |
| 418 | } | |
| 419 | ||
| 403 | 420 | /** `.gcode`: a sliced print, drawn as a table of its facts (layers, box, filament, time). Shares the web's parser. */ |
| 404 | 421 | function isGcodePath(path: string): boolean { |
| 405 | 422 | return /\.(gcode|gco)$/i.test(path); |
| @@ -486,6 +503,8 @@ class Tab { | ||
| 486 | 503 | grid: Grid = { header: "", rows: [] }; |
| 487 | 504 | /** Cues of a subtitle file that could not be read. */ |
| 488 | 505 | unread = 0; |
| 506 | /** Why a file whose name promises a view is shown as source instead; "" when nothing failed. */ | |
| 507 | fault = ""; | |
| 489 | 508 | /** The .ipynb / .log view, and the source it replaces while `s` has it up. */ |
| 490 | 509 | kindLines: KindLines | null = null; |
| 491 | 510 | sourceLines: KindLines | null = null; |
| @@ -586,6 +605,7 @@ class Tab { | ||
| 586 | 605 | this.plain = built.plain; |
| 587 | 606 | this.lines = built.lines; |
| 588 | 607 | this.unread = 0; |
| 608 | this.fault = ""; | |
| 589 | 609 | this.kindLines = null; |
| 590 | 610 | this.sourceLines = built; |
| 591 | 611 | const kind = kindView(this.path, text, c); |
| @@ -616,6 +636,11 @@ class Tab { | ||
| 616 | 636 | } else if (isGpxPath(this.path)) { |
| 617 | 637 | this.table = gpxTable(new TextDecoder().decode(source), this.path); |
| 618 | 638 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 639 | } else if (isSarifPath(this.path)) { | |
| 640 | const findings = sarifTable(new TextDecoder().decode(source)); | |
| 641 | this.table = typeof findings === "string" ? null : findings; | |
| 642 | this.fault = typeof findings === "string" ? findings : ""; | |
| 643 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 619 | 644 | } else if (isGcodePath(this.path)) { |
| 620 | 645 | this.table = gcodeTable(new TextDecoder().decode(source)); |
| 621 | 646 | if (this.table !== null) this.grid = layoutTable(this.table); |
| @@ -2730,7 +2755,7 @@ export function workspaceScreen( | ||
| 2730 | 2755 | ? "" |
| 2731 | 2756 | : `${t.path} · ${ |
| 2732 | 2757 | t.tabular && t.table !== null |
| 2733 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path), isJsonlPath(t.path)) | |
| 2758 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path), isJsonlPath(t.path), isSarifPath(t.path)) | |
| 2734 | 2759 | : `${plural( |
| 2735 | 2760 | t.reading ? t.doc.length : t.plain.length, |
| 2736 | 2761 | "line", |
| @@ -2738,7 +2763,7 @@ export function workspaceScreen( | ||
| 2738 | 2763 | t.table?.error === undefined || t.mode === "edit" |
| 2739 | 2764 | ? "" |
| 2740 | 2765 | : ` · ${tableProblem(t.table)}` |
| 2741 | }` | |
| 2766 | }${t.fault === "" || t.mode === "edit" ? "" : ` · ${t.fault}`}` | |
| 2742 | 2767 | }${ |
| 2743 | 2768 | writable() || target !== undefined ? "" : forkable() ? " · proposes from your fork" : " · read only" |
| 2744 | 2769 | }`; |
| @@ -3064,9 +3089,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 3064 | 3089 | } |
| 3065 | 3090 | |
| 3066 | 3091 | /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */ |
| 3067 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false, jsonl = false): string { | |
| 3092 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false, jsonl = false, findings = false): string { | |
| 3068 | 3093 | const rows = table.rows.length + table.more; |
| 3069 | return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : "row")}${ | |
| 3094 | return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : findings ? "finding" : "row")}${ | |
| 3070 | 3095 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 3071 | 3096 | }${ |
| 3072 | 3097 | table.more > 0 |
src/view/local.ts +2 −2
| @@ -30,6 +30,7 @@ import { listReviews } from "../core/reviews.ts"; | ||
| 30 | 30 | import type { User } from "../core/users.ts"; |
| 31 | 31 | import { diffCommit, type Diff } from "../git/diff.ts"; |
| 32 | 32 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| 33 | import { pathsAtTree } from "../git/path_listing.ts"; | |
| 33 | 34 | import { |
| 34 | 35 | blobForRender, |
| 35 | 36 | blobHead, |
| @@ -37,7 +38,6 @@ import { | ||
| 37 | 38 | listRefs, |
| 38 | 39 | listTree, |
| 39 | 40 | type TreeEntry, |
| 40 | listPaths, | |
| 41 | 41 | type PathListing, |
| 42 | 42 | } from "../git/read.ts"; |
| 43 | 43 | import { repoDir } from "../git/repos.ts"; |
| @@ -197,7 +197,7 @@ export function localSource( | ||
| 197 | 197 | |
| 198 | 198 | async paths(ref: string): Promise<Result<PathListing, SourceError>> { |
| 199 | 199 | if (!isSafeRef(ref)) return err("not_found"); |
| 200 | const listed = await listPaths(dir, ref); | |
| 200 | const listed = await pathsAtTree(dir, ref); | |
| 201 | 201 | return listed.ok ? ok(listed.value) : err("not_found"); |
| 202 | 202 | }, |
| 203 | 203 |
src/web/pages/browse.ts +4
| @@ -87,10 +87,12 @@ import { | ||
| 87 | 87 | } from "./explorer.ts"; |
| 88 | 88 | import { accountOnly, hostLabel, originIcon } from "./origins.ts"; |
| 89 | 89 | import { refSetFrom } from "./refs.ts"; |
| 90 | import { kindByName } from "../render/kinds.ts"; | |
| 90 | 91 | import { stageRow, stageStateFor } from "../stage-row.ts"; |
| 91 | 92 | import { |
| 92 | 93 | blobView, |
| 93 | 94 | codePage, |
| 95 | pathsAt, | |
| 94 | 96 | renderBlob, |
| 95 | 97 | renderCode, |
| 96 | 98 | rawResponse, |
| @@ -508,10 +510,12 @@ export async function browseBlob(ctx: Ctx): Promise<Response> { | ||
| 508 | 510 | const blob = await scope.source.blob(ref, path); |
| 509 | 511 | if (!blob.ok) return notFoundPage(ctx); |
| 510 | 512 | const refs = await refSetFrom(scope.source); |
| 513 | const exists = kindByName(path) === "sarif" ? await pathsAt(scope, ref) : undefined; | |
| 511 | 514 | const pane = renderBlob(scope, { |
| 512 | 515 | refs, |
| 513 | 516 | ref, |
| 514 | 517 | path, |
| 518 | ...(exists !== undefined ? { exists } : {}), | |
| 515 | 519 | blob: blob.value, |
| 516 | 520 | view: blobView(ctx.url.searchParams.get("view")), |
| 517 | 521 | all: ctx.url.searchParams.get("all") === "1", |
src/web/pages/repo.ts +13
| @@ -1156,6 +1156,14 @@ export function drawsFont(scope: RepoScope, path: string, blob: Blob, view: Blob | ||
| 1156 | 1156 | ); |
| 1157 | 1157 | } |
| 1158 | 1158 | |
| 1159 | /** The files at a ref as a lookup; undefined when the listing is unavailable or cut short (then nothing is linked). */ | |
| 1160 | export async function pathsAt(scope: RepoScope, ref: string): Promise<((path: string) => boolean) | undefined> { | |
| 1161 | const listed = await scope.source.paths(ref); | |
| 1162 | if (!listed.ok || listed.value.truncated) return undefined; | |
| 1163 | const set = new Set(listed.value.paths); | |
| 1164 | return (path) => set.has(path); | |
| 1165 | } | |
| 1166 | ||
| 1159 | 1167 | /** |
| 1160 | 1168 | * One file, framed by the same path header the tree uses. The header is |
| 1161 | 1169 | * sticky and the whole pane is one element, because a script swapping a |
| @@ -1179,6 +1187,8 @@ export function renderBlob( | ||
| 1179 | 1187 | layer?: number; |
| 1180 | 1188 | /** Rows a worker already built for this file. */ |
| 1181 | 1189 | rows?: CodeRows; |
| 1190 | /** The files at the ref, for a renderer that links paths. */ | |
| 1191 | exists?: (path: string) => boolean; | |
| 1182 | 1192 | }, |
| 1183 | 1193 | ): Html { |
| 1184 | 1194 | const base = scope.base; |
| @@ -1196,6 +1206,7 @@ export function renderBlob( | ||
| 1196 | 1206 | download: downloadHref(base, o.ref, o.path), |
| 1197 | 1207 | pathHref: `${base}/blob/${encoded}/${o.path}`, |
| 1198 | 1208 | repo: { base, ref: encoded }, |
| 1209 | ...(o.exists !== undefined ? { exists: o.exists } : {}), | |
| 1199 | 1210 | all: o.all === true, |
| 1200 | 1211 | ...(o.table !== undefined ? { table: o.table } : {}), |
| 1201 | 1212 | ...(o.layer !== undefined ? { layer: o.layer } : {}), |
| @@ -1762,8 +1773,10 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1762 | 1773 | rows = await rowsOffThread(shown.body, path, all ? MAX_RENDER_LINES : FIRST_LINES); |
| 1763 | 1774 | if (rows !== undefined) notePhase(ctx.request, "highlight", performance.now() - began); |
| 1764 | 1775 | } |
| 1776 | const exists = kindByName(path) === "sarif" ? await pathsAt(scope, ref) : undefined; | |
| 1765 | 1777 | const pane = renderBlob(scope, { |
| 1766 | 1778 | ...(rows !== undefined ? { rows } : {}), |
| 1779 | ...(exists !== undefined ? { exists } : {}), | |
| 1767 | 1780 | refs, |
| 1768 | 1781 | ref, |
| 1769 | 1782 | path, |
src/web/render/kinds.ts +3
| @@ -37,6 +37,7 @@ export type FileKind = | ||
| 37 | 37 | | "contacts" |
| 38 | 38 | | "mail" |
| 39 | 39 | | "gcode" |
| 40 | | "sarif" | |
| 40 | 41 | | "text" |
| 41 | 42 | | "binary"; |
| 42 | 43 | |
| @@ -72,6 +73,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 72 | 73 | contacts: 5 * MB, |
| 73 | 74 | mail: 5 * MB, |
| 74 | 75 | gcode: 50 * MB, |
| 76 | sarif: 5 * MB, | |
| 75 | 77 | text: 1 * MB, |
| 76 | 78 | binary: 0, |
| 77 | 79 | }; |
| @@ -193,6 +195,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 193 | 195 | eml: "mail", |
| 194 | 196 | gcode: "gcode", |
| 195 | 197 | gco: "gcode", |
| 198 | sarif: "sarif", | |
| 196 | 199 | }; |
| 197 | 200 | |
| 198 | 201 | /** |
src/web/render/renderers.ts +4
| @@ -27,6 +27,7 @@ import { model3d } from "./model3d.ts"; | ||
| 27 | 27 | import { notebook } from "./notebook.ts"; |
| 28 | 28 | import { patch } from "./patch.ts"; |
| 29 | 29 | import { pdf } from "./pdf.ts"; |
| 30 | import { sarif } from "./sarif.ts"; | |
| 30 | 31 | import { sqlite } from "./sqlite.ts"; |
| 31 | 32 | import { subtitles } from "./subtitles.ts"; |
| 32 | 33 | import { video } from "./video.ts"; |
| @@ -48,6 +49,8 @@ export interface RenderInput { | ||
| 48 | 49 | readonly pathHref?: string; |
| 49 | 50 | /** The repository base and ref (as written in a URL), so a relative address resolves. */ |
| 50 | 51 | readonly repo?: { readonly base: string; readonly ref: string }; |
| 52 | /** Whether a repository-relative path is a file at the ref; a renderer that links paths links only these. */ | |
| 53 | readonly exists?: (path: string) => boolean; | |
| 51 | 54 | /** `?all=1`: draw past the default window, up to the renderer's cap. */ |
| 52 | 55 | readonly all?: boolean; |
| 53 | 56 | /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */ |
| @@ -89,6 +92,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 89 | 92 | geojson, |
| 90 | 93 | calendar: ics, |
| 91 | 94 | contacts: vcf, |
| 95 | sarif, | |
| 92 | 96 | mail: eml, |
| 93 | 97 | archive, |
| 94 | 98 | font, |
src/web/render/sarif.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** | |
| 2 | * A SARIF file (Static Analysis Results Interchange Format 2.1.0, OASIS) as | |
| 3 | * its findings: one row per result (level, rule id, `file:line`, message), | |
| 4 | * grouped under each run's tool name (§3.14 `run.tool.driver.name`), with the | |
| 5 | * counts by level (§3.27.10 `result.level`: error, warning, note, none; an | |
| 6 | * absent level is a warning) at the top. A location is a link to that line in | |
| 7 | * the same repository when its URI is a plain relative path and the file | |
| 8 | * exists at the ref (`RenderInput.exists`); otherwise it is plain text. Every string is escaped. At most `RESULT_CAP` results | |
| 9 | * are drawn, and the page says how many were left out. Invalid JSON, or a | |
| 10 | * file with no `runs` array, falls back to the source with the reason. | |
| 11 | */ | |
| 12 | ||
| 13 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 14 | import { fileSize } from "../ui.ts"; | |
| 15 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 16 | ||
| 17 | /** Results drawn across all runs, whatever the file holds. */ | |
| 18 | export const RESULT_CAP = 1000; | |
| 19 | ||
| 20 | const LEVELS = ["error", "warning", "note", "none"] as const; | |
| 21 | type Level = (typeof LEVELS)[number]; | |
| 22 | ||
| 23 | export interface Finding { | |
| 24 | readonly level: Level; | |
| 25 | readonly rule: string; | |
| 26 | /** Repository-relative path, or the URI as written when it is not one. */ | |
| 27 | readonly file: string; | |
| 28 | readonly line: number; | |
| 29 | readonly message: string; | |
| 30 | } | |
| 31 | export interface Run { | |
| 32 | readonly tool: string; | |
| 33 | readonly results: readonly Finding[]; | |
| 34 | } | |
| 35 | export type Sarif = { readonly runs: Run[] } | { readonly error: string }; | |
| 36 | ||
| 37 | const isObj = (v: unknown): v is Record<string, unknown> => | |
| 38 | typeof v === "object" && v !== null && !Array.isArray(v); | |
| 39 | const str = (v: unknown): string => (typeof v === "string" ? v : ""); | |
| 40 | ||
| 41 | /** A `file:` or relative URI as a path from the repository root, if it is one. */ | |
| 42 | export function relativePath(uri: string): string | undefined { | |
| 43 | if (uri === "" || uri.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(uri)) return undefined; | |
| 44 | let path: string; | |
| 45 | try { | |
| 46 | path = decodeURIComponent(uri); | |
| 47 | } catch { | |
| 48 | return undefined; | |
| 49 | } | |
| 50 | if (/[\x00-\x1f\x7f\\?#]/.test(path)) return undefined; | |
| 51 | const parts = path.split("/").filter((p) => p !== "" && p !== "."); | |
| 52 | if (parts.length === 0 || parts.some((p) => p === "..") || parts.join("/").length > 4096) return undefined; | |
| 53 | return parts.join("/"); | |
| 54 | } | |
| 55 | ||
| 56 | function finding(r: unknown): Finding { | |
| 57 | const o = isObj(r) ? r : {}; | |
| 58 | const level = LEVELS.find((l) => l === o.level) ?? "warning"; | |
| 59 | const rule = str(o.ruleId) || (isObj(o.rule) ? str(o.rule.id) : ""); | |
| 60 | const msg = isObj(o.message) ? str(o.message.text) || str(o.message.markdown) : ""; | |
| 61 | const loc = Array.isArray(o.locations) && isObj(o.locations[0]) ? o.locations[0] : {}; | |
| 62 | const phys = isObj(loc.physicalLocation) ? loc.physicalLocation : {}; | |
| 63 | const uri = isObj(phys.artifactLocation) ? str(phys.artifactLocation.uri) : ""; | |
| 64 | const start = isObj(phys.region) ? phys.region.startLine : undefined; | |
| 65 | const line = typeof start === "number" && Number.isInteger(start) && start > 0 ? start : 0; | |
| 66 | return { level, rule, file: relativePath(uri) ?? uri, line, message: msg }; | |
| 67 | } | |
| 68 | ||
| 69 | /** Parse a SARIF file's text. Pure; never throws. */ | |
| 70 | export function parseSarif(source: string): Sarif { | |
| 71 | let doc: unknown; | |
| 72 | try { | |
| 73 | doc = JSON.parse(source.replace(/^/, "")); | |
| 74 | } catch (e) { | |
| 75 | return { error: `it is not valid JSON (${e instanceof Error ? e.message : "parse error"})` }; | |
| 76 | } | |
| 77 | if (!isObj(doc) || !Array.isArray(doc.runs)) return { error: "it has no `runs` array" }; | |
| 78 | const runs = doc.runs.map((run): Run => { | |
| 79 | const r = isObj(run) ? run : {}; | |
| 80 | const driver = isObj(r.tool) && isObj(r.tool.driver) ? r.tool.driver : {}; | |
| 81 | return { | |
| 82 | tool: str(driver.name) || "Unnamed tool", | |
| 83 | results: Array.isArray(r.results) ? r.results.map(finding) : [], | |
| 84 | }; | |
| 85 | }); | |
| 86 | return { runs }; | |
| 87 | } | |
| 88 | ||
| 89 | const encodePath = (path: string): string => path.split("/").map(encodeURIComponent).join("/"); | |
| 90 | ||
| 91 | export function sarif(input: RenderInput): Rendered { | |
| 92 | const doc = parseSarif(new TextDecoder().decode(input.body)); | |
outdated
No terminal screen in src/tui/ draws this; the page and the terminal must both open a .sarif as findings. parseSarif is already pure and reusable. |
||
| 93 | if ("error" in doc) return { content: html``, lines: 0, fallback: `This file is not drawn as findings: ${doc.error}.` }; | |
| 94 | const total = doc.runs.reduce((n, r) => n + r.results.length, 0); | |
| 95 | const counts: Record<Level, number> = { error: 0, warning: 0, note: 0, none: 0 }; | |
| 96 | for (const run of doc.runs) for (const f of run.results) counts[f.level]++; | |
| 97 | ||
| 98 | const where = (f: Finding): string => { | |
| 99 | const text = f.file === "" ? "—" : escapeHtml(f.line > 0 ? `${f.file}:${f.line}` : f.file); | |
| 100 | if (input.repo === undefined || input.exists === undefined || !input.exists(f.file) || f.file === "" || relativePath(f.file) !== f.file) return text; | |
outdated
Links every plain relative path without checking that it exists at the ref (issue box 1). Check existence and render text when missing; test both. |
||
| 101 | const href = `${input.repo.base}/blob/${input.repo.ref}/${encodePath(f.file)}${f.line > 0 ? `${f.line}` : ""}`; | |
| 102 | return `<a href="${escapeHtml(href)}">${text}</a>`; | |
| 103 | }; | |
| 104 | ||
| 105 | let left = RESULT_CAP; | |
| 106 | let groups = ""; | |
| 107 | for (const run of doc.runs) { | |
| 108 | const shown = run.results.slice(0, left); | |
| 109 | left -= shown.length; | |
| 110 | let rows = ""; | |
| 111 | for (const f of shown) | |
| 112 | rows += `<tr><td>${f.level}</td><td>${escapeHtml(f.rule)}</td><td>${where(f)}</td><td>${escapeHtml(f.message).replace(/\n/g, "<br>")}</td></tr>`; | |
| 113 | const n = run.results.length; | |
| 114 | groups += `<h3>${escapeHtml(run.tool)} <span class="muted small">${n.toLocaleString()} ${n === 1 ? "result" : "results"}</span></h3>`; | |
| 115 | groups += | |
| 116 | shown.length === 0 | |
| 117 | ? `<p class="muted small">No results.</p>` | |
| 118 | : `<table class="table" data-dense aria-label="${escapeHtml(run.tool)} findings"><thead><tr><th scope="col">Level</th><th scope="col">Rule</th><th scope="col">Location</th><th scope="col">Message</th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 119 | } | |
| 120 | const out = total - Math.min(total, RESULT_CAP); | |
| 121 | const more = | |
| 122 | out > 0 | |
| 123 | ? `<p class="muted small">Showing ${RESULT_CAP.toLocaleString()} of ${total.toLocaleString()} results, ${out.toLocaleString()} left out. The rest is in the raw file.</p>` | |
| 124 | : ""; | |
| 125 | const tally = LEVELS.map((l) => `${counts[l].toLocaleString()} ${l}`).join(" · "); | |
| 126 | const content = html`<div class="sarif-view"><p>${tally}</p>${raw(groups)}${raw(more)}</div>`; | |
| 127 | return { content, lines: 0, summary: `${total.toLocaleString()} ${total === 1 ? "result" : "results"} · ${fileSize(input.size)}` }; | |
outdated
aria-label on a div with no role is not announced. Drop it (the h3 headings already name each table) or add role="region". Principle 8/accessibility: a screen reader should get a landmark or nothing. |
||
| 128 | } |
test/blob-sarif-page.test.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * A `.sarif` committed to a repository, opened on its blob page over HTTP by | |
| 3 | * an anonymous reader: the server-rendered route and its facts as rows. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | describe("a .sarif on its blob page", () => { | |
| 19 | let dataDir = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let base = ""; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-blob-sarif-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | base = servers.url; | |
| 29 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-patch-passphrase" }); | |
| 30 | if (!a.ok) throw new Error("signup failed"); | |
| 31 | const created = await createRepo(app, a.value, { name: "p", visibility: "public" }); | |
| 32 | if (!created.ok) throw new Error("repo failed"); | |
| 33 | const fixture = JSON.stringify({ | |
| 34 | version: "2.1.0", | |
| 35 | runs: [{ tool: { driver: { name: "T" } }, results: ["src/a.ts", "gone/b.ts"].map((uri, i) => ({ ruleId: "R", level: "error", message: { text: "m" }, locations: [{ physicalLocation: { artifactLocation: { uri }, region: { startLine: i + 1 } } }] })) }], | |
| 36 | }); | |
| 37 | const made = await commitToBranch(app, a.value, created.value, { | |
| 38 | message: "a track", | |
| 39 | files: [{ path: "scan.sarif", content: fixture }, { path: "src/a.ts", content: "x\n" }], | |
| 40 | }); | |
| 41 | if (!made.ok) throw new Error("commit failed"); | |
| 42 | }, 60_000); | |
| 43 | ||
| 44 | afterAll(async () => { | |
| 45 | await servers?.stop(); | |
| 46 | app?.close(); | |
| 47 | await rm(dataDir, { recursive: true, force: true }); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a finding links only to a file that exists at the ref", async () => { | |
| 51 | const res = await fetch(`${base}/alice/p/blob/main/scan.sarif`); | |
| 52 | const text = await res.text(); | |
| 53 | expect(res.status).toBe(200); | |
| 54 | expect(text).toContain('<a href="/alice/p/blob/main/src/a.ts#L1">src/a.ts:1</a>'); | |
| 55 | expect(text).toContain("<td>gone/b.ts:2</td>"); | |
| 56 | expect(text).not.toContain("blob/main/gone/b.ts"); | |
| 57 | }); | |
| 58 | }); |
test/fuzz-renderers.test.ts +6
| @@ -19,6 +19,7 @@ import { geojson } from "../src/web/render/geojson.ts"; | ||
| 19 | 19 | import { subtitles } from "../src/web/render/subtitles.ts"; |
| 20 | 20 | import { patch } from "../src/web/render/patch.ts"; |
| 21 | 21 | import { notebook } from "../src/web/render/notebook.ts"; |
| 22 | import { sarif } from "../src/web/render/sarif.ts"; | |
| 22 | 23 | import { gcode } from "../src/web/render/gcode.ts"; |
| 23 | 24 | import { config } from "../src/web/render/config.ts"; |
| 24 | 25 | import { markdown } from "../src/web/render/markdown.ts"; |
| @@ -109,6 +110,11 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly | ||
| 109 | 110 | '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"A","url":"javascript:alert(1)"},"geometry":{"type":"Point","coordinates":[0.1,51.5]}},{"type":"Feature","properties":{"k":"v"},"geometry":{"type":"LineString","coordinates":[[0,0],[1,1]]}}]}', |
| 110 | 111 | ], |
| 111 | 112 | }, |
| 113 | sarif: { | |
| 114 | path: "scan.sarif", | |
| 115 | render: sarif, | |
| 116 | files: ['{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"T"}},"results":[{"ruleId":"R","level":"error","message":{"text":"m"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/a.ts"},"region":{"startLine":3}}}]}]}]}'], | |
| 117 | }, | |
| 112 | 118 | srt: { |
| 113 | 119 | path: "talk.srt", |
| 114 | 120 | render: subtitles, |
test/path-listing.test.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** Path listings kept by tree id: a second ask reuses the first, a moved branch never reads stale. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { keptListings, pathsAtTree } from "../src/git/path_listing.ts"; | |
| 7 | import { closeReader } from "../src/git/read.ts"; | |
| 8 | ||
| 9 | let dir: string; | |
| 10 | const git = (...args: string[]) => { | |
| 11 | const r = Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { cwd: dir }); | |
| 12 | if (r.exitCode !== 0) throw new Error(r.stderr.toString()); | |
| 13 | }; | |
| 14 | const commit = async (file: string) => { | |
| 15 | await writeFile(join(dir, file), file); | |
| 16 | git("add", file); | |
| 17 | git("commit", "-qm", file); | |
| 18 | }; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dir = await mkdtemp(join(tmpdir(), "forge-path-listing-")); | |
| 22 | git("init", "-q", "-b", "main"); | |
| 23 | await commit("a.ts"); | |
| 24 | }); | |
| 25 | ||
| 26 | afterAll(async () => { | |
| 27 | closeReader(dir); | |
| 28 | await rm(dir, { recursive: true, force: true }); | |
| 29 | }); | |
| 30 | ||
| 31 | test("the same tree asked twice is listed once: the second answer is the kept one", async () => { | |
| 32 | const first = await pathsAtTree(dir, "main"); | |
| 33 | const before = keptListings(); | |
| 34 | const second = await pathsAtTree(dir, "main"); | |
| 35 | expect(first.ok && second.ok).toBe(true); | |
| 36 | if (!first.ok || !second.ok) return; | |
| 37 | expect(first.value.paths).toEqual(["a.ts"]); | |
| 38 | expect(second.value).toBe(first.value); | |
| 39 | expect(keptListings()).toBe(before); | |
| 40 | }); | |
| 41 | ||
| 42 | test("a branch that moves names a new tree, so its listing is fresh", async () => { | |
| 43 | await pathsAtTree(dir, "main"); | |
| 44 | await commit("b.ts"); | |
| 45 | const moved = await pathsAtTree(dir, "main"); | |
| 46 | expect(moved.ok && moved.value.paths).toEqual(["a.ts", "b.ts"]); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a ref that names nothing is missing, and an unsafe one never reaches git", async () => { | |
| 50 | expect((await pathsAtTree(dir, "nope")).ok).toBe(false); | |
| 51 | expect((await pathsAtTree(dir, "-x")).ok).toBe(false); | |
| 52 | }); |
test/render-kinds.test.ts +1
| @@ -81,6 +81,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 81 | 81 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], |
| 82 | 82 | gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"], |
| 83 | 83 | gco: [enc("G28\n"), "gcode", "application/octet-stream"], |
| 84 | sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"], | |
| 84 | 85 | }; |
| 85 | 86 | |
| 86 | 87 | describe("the registry of file kinds", () => { |
test/render-sarif.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** SARIF files as findings: two runs, level counts, the cap, escaping, links, the fallback. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { RESULT_CAP, parseSarif, relativePath, sarif } from "../src/web/render/sarif.ts"; | |
| 4 | import { fileKind } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (text: string, repo?: { base: string; ref: string }, files: readonly string[] = ["src/a.ts", "src/b.ts", "lib/c.ts"]) => | |
| 9 | sarif({ path: "scan/out.sarif", size: text.length, body: enc(text), media: "/m", download: "/d", ...(repo ? { repo, exists: (p: string) => files.includes(p) } : {}) }); | |
| 10 | const result = (o: object) => ({ ruleId: "R1", level: "error", message: { text: "m" }, ...o }); | |
| 11 | const at = (uri: string, line: number) => ({ locations: [{ physicalLocation: { artifactLocation: { uri }, region: { startLine: line } } }] }); | |
| 12 | const doc = (...runs: object[]) => JSON.stringify({ version: "2.1.0", runs }); | |
| 13 | ||
| 14 | const TWO = doc( | |
| 15 | { tool: { driver: { name: "CodeQL" } }, results: [result({ ...at("src/a.ts", 12), message: { text: "tainted" } }), result({ ruleId: "R2", level: "note", ...at("src/b.ts", 3) })] }, | |
| 16 | { tool: { driver: { name: "Semgrep" } }, results: [{ ruleId: "S1", message: { text: "no level" }, ...at("lib/c.ts", 7) }] }, | |
| 17 | ); | |
| 18 | ||
| 19 | describe("sarif", () => { | |
| 20 | test("the registry names .sarif and a renderer draws it", () => { | |
| 21 | expect(fileKind("x/a.SARIF", enc("{}"))).toBe("sarif"); | |
| 22 | expect(RENDERERS.sarif).toBe(sarif); | |
| 23 | }); | |
| 24 | ||
| 25 | test("two runs: grouped by tool, rows with level, rule, linked location, message, counts on top", () => { | |
| 26 | const r = draw(TWO, { base: "/o/n", ref: "main" }); | |
| 27 | expect(r.fallback).toBeUndefined(); | |
| 28 | const h = String(r.content); | |
| 29 | expect(h).toContain("1 error · 1 warning · 1 note · 0 none"); | |
| 30 | expect(h.indexOf("CodeQL")).toBeLessThan(h.indexOf("Semgrep")); | |
| 31 | expect(h).toContain('<a href="/o/n/blob/main/src/a.ts#L12">src/a.ts:12</a>'); | |
| 32 | expect(h).toContain("<td>R2</td>"); | |
| 33 | expect(h).toContain("<td>warning</td><td>S1</td>"); | |
| 34 | expect(h).toContain("tainted"); | |
| 35 | expect(r.summary).toContain("3 results"); | |
| 36 | }); | |
| 37 | ||
| 38 | test("the wrapper is a plain div: no aria-label a screen reader would ignore", () => { | |
| 39 | const h = String(draw(TWO).content); | |
| 40 | expect(h).toContain('<div class="sarif-view">'); | |
| 41 | }); | |
| 42 | ||
| 43 | test("a location links only when the file exists at the ref; otherwise it is plain text", () => { | |
| 44 | const h = String(draw(TWO, { base: "/o/n", ref: "main" }, ["src/a.ts"]).content); | |
| 45 | expect(h).toContain('<a href="/o/n/blob/main/src/a.ts#L12">src/a.ts:12</a>'); | |
| 46 | expect(h).not.toContain("blob/main/src/b.ts"); | |
| 47 | expect(h).toContain("<td>src/b.ts:3</td>"); | |
| 48 | expect(h).toContain("<td>lib/c.ts:7</td>"); | |
| 49 | const none = sarif({ path: "x.sarif", size: 1, body: enc(TWO), media: "/m", download: "/d", repo: { base: "/o/n", ref: "main" } }); | |
| 50 | expect(String(none.content)).not.toContain("<a "); | |
| 51 | }); | |
| 52 | ||
| 53 | test("the cap draws 1,000 results and says how many were left out", () => { | |
| 54 | const results = Array.from({ length: RESULT_CAP + 5 }, (_, i) => result(at("f.ts", i + 1))); | |
| 55 | const h = String(draw(doc({ tool: { driver: { name: "T" } }, results })).content); | |
| 56 | expect(h.match(/<tr><td>/g)?.length).toBe(RESULT_CAP); | |
| 57 | expect(h).toContain("Showing 1,000 of 1,005 results, 5 left out"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a <script> message, rule, tool and path arrive escaped", () => { | |
| 61 | const evil = "<script>alert(1)</script>"; | |
| 62 | const h = String( | |
| 63 | draw(doc({ tool: { driver: { name: evil } }, results: [result({ ruleId: evil, message: { text: evil }, ...at(evil, 1) })] }), { base: "/o/n", ref: "main" }).content, | |
| 64 | ); | |
| 65 | expect(h).not.toContain("<script"); | |
| 66 | expect(h).toContain("<script>"); | |
| 67 | }); | |
| 68 | ||
| 69 | test("an absolute or escaping location is text, never a link", () => { | |
| 70 | expect(relativePath("../x")).toBeUndefined(); | |
| 71 | expect(relativePath("https://a.test/x")).toBeUndefined(); | |
| 72 | expect(relativePath("./a/b.ts")).toBe("a/b.ts"); | |
| 73 | const h = String(draw(doc({ tool: { driver: { name: "T" } }, results: [result(at("javascript:alert(1)", 1))] }), { base: "/o/n", ref: "main" }).content); | |
| 74 | expect(h).not.toContain("<a "); | |
| 75 | }); | |
| 76 | ||
| 77 | test("invalid JSON and a file with no runs fall back with the reason", () => { | |
| 78 | expect(draw("{nope").fallback).toContain("not valid JSON"); | |
| 79 | expect(draw('{"version":"2.1.0"}').fallback).toContain("no `runs`"); | |
| 80 | expect(draw("[]").fallback).toContain("no `runs`"); | |
| 81 | expect(parseSarif("null")).toEqual({ error: "it has no `runs` array" }); | |
| 82 | }); | |
| 83 | }); |
test/tui-workspace.test.ts +39
| @@ -528,6 +528,45 @@ describe("the terminal workspace", () => { | ||
| 528 | 528 | } |
| 529 | 529 | }); |
| 530 | 530 | |
| 531 | test("a .sarif opens as its findings, sharing the web parser, and s flips to the source", async () => { | |
| 532 | const made = await createRepo(forge, alice, { name: "scans", visibility: "public", description: "findings" }); | |
| 533 | if (!made.ok) throw new Error("repo failed"); | |
| 534 | const sarif = JSON.stringify({ | |
| 535 | version: "2.1.0", | |
| 536 | runs: [{ tool: { driver: { name: "CodeQL" } }, results: [{ ruleId: "js/xss", level: "error", message: { text: "tainted value" }, locations: [{ physicalLocation: { artifactLocation: { uri: "src/a.ts" }, region: { startLine: 12 } } }] }] }], | |
| 537 | }); | |
| 538 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "scan.sarif", content: sarif }] }); | |
| 539 | const h = boot(); | |
| 540 | try { | |
| 541 | await h.settle(); | |
| 542 | await open(h, "scans"); | |
| 543 | await openFile(h, "scan.sarif"); | |
| 544 | expect(h.text()).toContain("js/xss"); | |
| 545 | expect(h.text()).toContain("src/a.ts:12"); | |
| 546 | expect(h.text()).toContain("1 finding"); | |
| 547 | await h.press("s"); | |
| 548 | expect(h.text()).toContain("runs"); | |
| 549 | } finally { | |
| 550 | h.stop(); | |
| 551 | } | |
| 552 | }); | |
| 553 | ||
| 554 | test("a .sarif that is not SARIF opens as source and the status line says why", async () => { | |
| 555 | const made = await createRepo(forge, alice, { name: "badscans", visibility: "public", description: "findings" }); | |
| 556 | if (!made.ok) throw new Error("repo failed"); | |
| 557 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "scan.sarif", content: '{"version":"2.1.0"}' }] }); | |
| 558 | const h = boot(); | |
| 559 | try { | |
| 560 | await h.settle(); | |
| 561 | await open(h, "badscans"); | |
| 562 | await openFile(h, "scan.sarif"); | |
| 563 | expect(h.text()).toContain('"version"'); | |
| 564 | expect(h.text()).toContain("not drawn as findings: it has no `runs` array"); | |
| 565 | } finally { | |
| 566 | h.stop(); | |
| 567 | } | |
| 568 | }); | |
| 569 | ||
| 531 | 570 | test("a .gcode opens as its facts, sharing the web parser, and s flips to the source", async () => { |
| 532 | 571 | const made = await createRepo(forge, alice, { name: "prints3d", visibility: "public", description: "slices" }); |
| 533 | 572 | if (!made.ok) throw new Error("repo failed"); |
pathsAt landed between renderBlob's doc comment (above, 'One file, framed by the same path header...') and renderBlob, so that comment now documents pathsAt. Move pathsAt above that comment.