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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11029-a-sarif-file-in-a-repository-opens-as-it → main 6 commits fork 4 threads · 4 unresolved

  • Merged. torque/11029-a-sarif-file-in-a-repository-opens-as-it was merged into main as 3a0fba6.

Review

Sign in to review this change.
16 files changed +480 −6
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
3131 `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`.
3232
3333 `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";
5959 import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts";
6060 import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts";
6161 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
62 import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts";
6263 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
6364 import { fileSize } from "../../web/ui.ts";
6465 import { parseEml } from "../../web/render/eml-parse.ts";
@@ -400,6 +401,22 @@ function gpxTable(source: string, path: string): Table | null {
400401 return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 };
401402 }
402403
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
403420 /** `.gcode`: a sliced print, drawn as a table of its facts (layers, box, filament, time). Shares the web's parser. */
404421 function isGcodePath(path: string): boolean {
405422 return /\.(gcode|gco)$/i.test(path);
@@ -486,6 +503,8 @@ class Tab {
486503 grid: Grid = { header: "", rows: [] };
487504 /** Cues of a subtitle file that could not be read. */
488505 unread = 0;
506 /** Why a file whose name promises a view is shown as source instead; "" when nothing failed. */
507 fault = "";
489508 /** The .ipynb / .log view, and the source it replaces while `s` has it up. */
490509 kindLines: KindLines | null = null;
491510 sourceLines: KindLines | null = null;
@@ -586,6 +605,7 @@ class Tab {
586605 this.plain = built.plain;
587606 this.lines = built.lines;
588607 this.unread = 0;
608 this.fault = "";
589609 this.kindLines = null;
590610 this.sourceLines = built;
591611 const kind = kindView(this.path, text, c);
@@ -616,6 +636,11 @@ class Tab {
616636 } else if (isGpxPath(this.path)) {
617637 this.table = gpxTable(new TextDecoder().decode(source), this.path);
618638 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);
619644 } else if (isGcodePath(this.path)) {
620645 this.table = gcodeTable(new TextDecoder().decode(source));
621646 if (this.table !== null) this.grid = layoutTable(this.table);
@@ -2730,7 +2755,7 @@ export function workspaceScreen(
27302755 ? ""
27312756 : `${t.path} · ${
27322757 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))
27342759 : `${plural(
27352760 t.reading ? t.doc.length : t.plain.length,
27362761 "line",
@@ -2738,7 +2763,7 @@ export function workspaceScreen(
27382763 t.table?.error === undefined || t.mode === "edit"
27392764 ? ""
27402765 : ` · ${tableProblem(t.table)}`
2741 }`
2766 }${t.fault === "" || t.mode === "edit" ? "" : ` · ${t.fault}`}`
27422767 }${
27432768 writable() || target !== undefined ? "" : forkable() ? " · proposes from your fork" : " · read only"
27442769 }`;
@@ -3064,9 +3089,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
30643089 }
30653090
30663091 /** `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 {
30683093 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")}${
30703095 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
30713096 }${
30723097 table.more > 0
src/view/local.ts +2 −2
@@ -30,6 +30,7 @@ import { listReviews } from "../core/reviews.ts";
3030 import type { User } from "../core/users.ts";
3131 import { diffCommit, type Diff } from "../git/diff.ts";
3232 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
33 import { pathsAtTree } from "../git/path_listing.ts";
3334 import {
3435 blobForRender,
3536 blobHead,
@@ -37,7 +38,6 @@ import {
3738 listRefs,
3839 listTree,
3940 type TreeEntry,
40 listPaths,
4141 type PathListing,
4242 } from "../git/read.ts";
4343 import { repoDir } from "../git/repos.ts";
@@ -197,7 +197,7 @@ export function localSource(
197197
198198 async paths(ref: string): Promise<Result<PathListing, SourceError>> {
199199 if (!isSafeRef(ref)) return err("not_found");
200 const listed = await listPaths(dir, ref);
200 const listed = await pathsAtTree(dir, ref);
201201 return listed.ok ? ok(listed.value) : err("not_found");
202202 },
203203
src/web/pages/browse.ts +4
@@ -87,10 +87,12 @@ import {
8787 } from "./explorer.ts";
8888 import { accountOnly, hostLabel, originIcon } from "./origins.ts";
8989 import { refSetFrom } from "./refs.ts";
90 import { kindByName } from "../render/kinds.ts";
9091 import { stageRow, stageStateFor } from "../stage-row.ts";
9192 import {
9293 blobView,
9394 codePage,
95 pathsAt,
9496 renderBlob,
9597 renderCode,
9698 rawResponse,
@@ -508,10 +510,12 @@ export async function browseBlob(ctx: Ctx): Promise<Response> {
508510 const blob = await scope.source.blob(ref, path);
509511 if (!blob.ok) return notFoundPage(ctx);
510512 const refs = await refSetFrom(scope.source);
513 const exists = kindByName(path) === "sarif" ? await pathsAt(scope, ref) : undefined;
511514 const pane = renderBlob(scope, {
512515 refs,
513516 ref,
514517 path,
518 ...(exists !== undefined ? { exists } : {}),
515519 blob: blob.value,
516520 view: blobView(ctx.url.searchParams.get("view")),
517521 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
11561156 );
11571157 }
11581158
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);
outdated

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.

1165 }
1166
11591167 /**
11601168 * One file, framed by the same path header the tree uses. The header is
11611169 * sticky and the whole pane is one element, because a script swapping a
@@ -1179,6 +1187,8 @@ export function renderBlob(
11791187 layer?: number;
11801188 /** Rows a worker already built for this file. */
11811189 rows?: CodeRows;
1190 /** The files at the ref, for a renderer that links paths. */
1191 exists?: (path: string) => boolean;
11821192 },
11831193 ): Html {
11841194 const base = scope.base;
@@ -1196,6 +1206,7 @@ export function renderBlob(
11961206 download: downloadHref(base, o.ref, o.path),
11971207 pathHref: `${base}/blob/${encoded}/${o.path}`,
11981208 repo: { base, ref: encoded },
1209 ...(o.exists !== undefined ? { exists: o.exists } : {}),
11991210 all: o.all === true,
12001211 ...(o.table !== undefined ? { table: o.table } : {}),
12011212 ...(o.layer !== undefined ? { layer: o.layer } : {}),
@@ -1762,8 +1773,10 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17621773 rows = await rowsOffThread(shown.body, path, all ? MAX_RENDER_LINES : FIRST_LINES);
17631774 if (rows !== undefined) notePhase(ctx.request, "highlight", performance.now() - began);
17641775 }
1776 const exists = kindByName(path) === "sarif" ? await pathsAt(scope, ref) : undefined;
17651777 const pane = renderBlob(scope, {
17661778 ...(rows !== undefined ? { rows } : {}),
1779 ...(exists !== undefined ? { exists } : {}),
17671780 refs,
17681781 ref,
17691782 path,
src/web/render/kinds.ts +3
@@ -37,6 +37,7 @@ export type FileKind =
3737 | "contacts"
3838 | "mail"
3939 | "gcode"
40 | "sarif"
4041 | "text"
4142 | "binary";
4243
@@ -72,6 +73,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
7273 contacts: 5 * MB,
7374 mail: 5 * MB,
7475 gcode: 50 * MB,
76 sarif: 5 * MB,
7577 text: 1 * MB,
7678 binary: 0,
7779 };
@@ -193,6 +195,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
193195 eml: "mail",
194196 gcode: "gcode",
195197 gco: "gcode",
198 sarif: "sarif",
196199 };
197200
198201 /**
src/web/render/renderers.ts +4
@@ -27,6 +27,7 @@ import { model3d } from "./model3d.ts";
2727 import { notebook } from "./notebook.ts";
2828 import { patch } from "./patch.ts";
2929 import { pdf } from "./pdf.ts";
30 import { sarif } from "./sarif.ts";
3031 import { sqlite } from "./sqlite.ts";
3132 import { subtitles } from "./subtitles.ts";
3233 import { video } from "./video.ts";
@@ -48,6 +49,8 @@ export interface RenderInput {
4849 readonly pathHref?: string;
4950 /** The repository base and ref (as written in a URL), so a relative address resolves. */
5051 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;
5154 /** `?all=1`: draw past the default window, up to the renderer's cap. */
5255 readonly all?: boolean;
5356 /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */
@@ -89,6 +92,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
8992 geojson,
9093 calendar: ics,
9194 contacts: vcf,
95 sarif,
9296 mail: eml,
9397 archive,
9498 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 ? `#L${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";
1919 import { subtitles } from "../src/web/render/subtitles.ts";
2020 import { patch } from "../src/web/render/patch.ts";
2121 import { notebook } from "../src/web/render/notebook.ts";
22 import { sarif } from "../src/web/render/sarif.ts";
2223 import { gcode } from "../src/web/render/gcode.ts";
2324 import { config } from "../src/web/render/config.ts";
2425 import { markdown } from "../src/web/render/markdown.ts";
@@ -109,6 +110,11 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly
109110 '{"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]]}}]}',
110111 ],
111112 },
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 },
112118 srt: {
113119 path: "talk.srt",
114120 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]> = {
8181 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
8282 gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"],
8383 gco: [enc("G28\n"), "gcode", "application/octet-stream"],
84 sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"],
8485 };
8586
8687 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("&lt;script&gt;");
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", () => {
528528 }
529529 });
530530
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
531570 test("a .gcode opens as its facts, sharing the web parser, and s flips to the source", async () => {
532571 const made = await createRepo(forge, alice, { name: "prints3d", visibility: "public", description: "slices" });
533572 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs