@decohere/ci-tools 0.0.1-dev.125 → 0.0.1-dev.13

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 (40) hide show
  1. package/package.json +3 -18
  2. package/src/audit/enforce.mjs +5 -7
  3. package/src/audit/summarize.mjs +32 -10
  4. package/src/audit/upsert-issue.mjs +74 -25
  5. package/src/bench/bench.mjs +368 -134
  6. package/src/bench/trace.mjs +14 -18
  7. package/src/coverage/enforce-thresholds.mjs +144 -0
  8. package/src/pages/artifact.mjs +183 -38
  9. package/src/pages/clear-bench-history.mjs +27 -0
  10. package/src/pages/keep-prior-bench-badge.mjs +38 -0
  11. package/src/pages/restore-bench-history.mjs +28 -15
  12. package/src/release/npm-links.mjs +13 -6
  13. package/src/release/sync-dependencies.mjs +2 -1
  14. package/tests/lib/lcov.test.mjs +4 -4
  15. package/LICENSE +0 -15
  16. package/src/audit/format.mjs +0 -133
  17. package/src/bench/file-regressions.mjs +0 -76
  18. package/src/bench/history.mjs +0 -248
  19. package/src/bench/nightly.mjs +0 -118
  20. package/src/bench/regression-issue.mjs +0 -44
  21. package/src/github/credential-nudge.mjs +0 -226
  22. package/src/github/npm-bootstrap.mjs +0 -179
  23. package/src/github/oidc-bootstrap-nudge.mjs +0 -214
  24. package/src/github/publish-verify.mjs +0 -206
  25. package/src/github/smoke-create-launcher.mjs +0 -70
  26. package/src/lib/github-issue.mjs +0 -75
  27. package/src/lib/workspace-packages.mjs +0 -30
  28. package/src/pages/bench-page.mjs +0 -323
  29. package/src/pages/restore-badges.mjs +0 -58
  30. package/tests/audit/format.test.mjs +0 -115
  31. package/tests/bench/history.test.mjs +0 -279
  32. package/tests/bench/regression-issue.test.mjs +0 -42
  33. package/tests/github/credential-nudge.test.mjs +0 -112
  34. package/tests/github/npm-bootstrap.test.mjs +0 -125
  35. package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
  36. package/tests/github/package-metadata.test.mjs +0 -69
  37. package/tests/github/publish-verify.test.mjs +0 -115
  38. package/tests/github/smoke-create-launcher.test.mjs +0 -71
  39. package/tests/lib/github-issue.test.mjs +0 -85
  40. package/tests/pages/bench-page.test.mjs +0 -123
@@ -1,323 +0,0 @@
1
- /**
2
- * The benchmark page on Pages (#660): a trend chart per benchmark and a table of the latest
3
- * results, from the version 2 history (../bench/history.mjs). Static HTML and inline SVG, so the
4
- * page needs no script and no third-party chart library.
5
- */
6
-
7
- import { REGRESSION, STALE_AFTER_DAYS } from "../bench/history.mjs";
8
-
9
- const GROUPS = [
10
- { prefix: "flux.serve.", title: "flux serve (real process)" },
11
- { prefix: "flux.load.", title: "flux load (in process)" },
12
- { prefix: "flux.transpile.", title: "Transpile strategies (SWC only)" },
13
- { prefix: "forge.build.", title: "forge build" },
14
- ];
15
-
16
- const SERIES_COLORS = [
17
- "var(--series-1)",
18
- "var(--series-2)",
19
- "var(--series-3)",
20
- "var(--series-4)",
21
- ];
22
-
23
- function escapeHtml(value) {
24
- return String(value)
25
- .replaceAll("&", "&")
26
- .replaceAll("<", "&lt;")
27
- .replaceAll(">", "&gt;")
28
- .replaceAll('"', "&quot;");
29
- }
30
-
31
- function fmtMs(ms) {
32
- if (typeof ms !== "number" || Number.isNaN(ms)) return "–";
33
- if (ms === 0) return "0 ms";
34
- if (ms >= 100) return `${Math.round(ms)} ms`;
35
- return `${ms.toFixed(1)} ms`;
36
- }
37
-
38
- /** `2026-10-20 03:17 UTC`: when a run happened, to the minute. */
39
- function fmtDate(iso) {
40
- return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
41
- }
42
-
43
- function commitLink(repoUrl, sha) {
44
- if (sha == null) return "–";
45
- return `<a href="${escapeHtml(`${repoUrl}/commit/${sha}`)}"><code>${escapeHtml(sha.slice(0, 7))}</code></a>`;
46
- }
47
-
48
- function machineName(context) {
49
- return `${context.benchEnv ?? "unknown"} · ${context.cpu.model} × ${context.cpu.count}`;
50
- }
51
-
52
- /**
53
- * Contexts with a run within STALE_AFTER_DAYS of the newest run anywhere, most recently run first:
54
- * CI runner types come and go, and one that stopped appearing would otherwise keep a line (#667).
55
- */
56
- function activeContexts(history) {
57
- const newest = Math.max(
58
- ...Object.values(history.contexts ?? {})
59
- .filter((entry) => entry.runs.length > 0)
60
- .map((entry) => new Date(entry.runs[0].date).getTime()),
61
- );
62
- const cutoff = newest - STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
63
- return Object.entries(history.contexts ?? {})
64
- .filter(([, entry]) => entry.runs.length > 0)
65
- .filter(([, entry]) => new Date(entry.runs[0].date).getTime() >= cutoff)
66
- .sort((a, b) => new Date(b[1].runs[0].date) - new Date(a[1].runs[0].date))
67
- .map(([id, entry], index) => ({
68
- id,
69
- context: entry.context,
70
- runs: entry.runs,
71
- color: SERIES_COLORS[index % SERIES_COLORS.length],
72
- }));
73
- }
74
-
75
- /** Every benchmark id seen in any run, with the label from its newest run on any machine. */
76
- function benchmarkIds(contexts) {
77
- const newest = new Map();
78
- for (const { runs } of contexts) {
79
- for (const run of runs) {
80
- const time = new Date(run.date).getTime();
81
- for (const result of run.results) {
82
- const seen = newest.get(result.id);
83
- if (seen == null || time > seen.time)
84
- newest.set(result.id, { time, label: result.label });
85
- }
86
- }
87
- }
88
- return new Map([...newest].map(([id, { label }]) => [id, label]));
89
- }
90
-
91
- /** Oldest-first points for one benchmark on one machine. */
92
- function seriesFor(runs, id) {
93
- return [...runs]
94
- .reverse()
95
- .map((run) => ({ run, result: run.results.find((result) => result.id === id) }))
96
- .filter((point) => point.result != null)
97
- .map(({ run, result }) => ({
98
- time: new Date(run.date).getTime(),
99
- date: run.date.slice(0, 10),
100
- iso: run.date,
101
- sha: run.sha,
102
- median: result.median,
103
- p90: result.p90,
104
- }));
105
- }
106
-
107
- /** A line chart of medians over time, one line per machine, with a hover title per point. */
108
- export function trendSvg(lines) {
109
- const width = 560;
110
- const height = 180;
111
- const pad = { top: 12, right: 12, bottom: 24, left: 56 };
112
- const points = lines.flatMap((line) => line.points);
113
- if (points.length === 0) return "";
114
- const minTime = Math.min(...points.map((p) => p.time));
115
- const maxTime = Math.max(...points.map((p) => p.time));
116
- const maxValue = Math.max(...points.map((p) => p.p90 ?? p.median)) * 1.1 || 1;
117
- const x = (time) =>
118
- maxTime === minTime
119
- ? pad.left + (width - pad.left - pad.right) / 2
120
- : pad.left + ((time - minTime) / (maxTime - minTime)) * (width - pad.left - pad.right);
121
- const y = (value) => pad.top + (1 - value / maxValue) * (height - pad.top - pad.bottom);
122
- const ticks = [0, 0.5, 1].map((fraction) => maxValue * fraction);
123
- const grid = ticks
124
- .map(
125
- (value) =>
126
- `<line class="grid" x1="${pad.left}" x2="${width - pad.right}" y1="${y(value)}" y2="${y(value)}"/>` +
127
- `<text class="axis" x="${pad.left - 6}" y="${y(value) + 4}" text-anchor="end">${fmtMs(value)}</text>`,
128
- )
129
- .join("");
130
- const dateLabels = [
131
- { time: minTime, anchor: "start" },
132
- ...(maxTime !== minTime ? [{ time: maxTime, anchor: "end" }] : []),
133
- ]
134
- .map(
135
- ({ time, anchor }) =>
136
- `<text class="axis" x="${x(time)}" y="${height - 6}" text-anchor="${anchor}">${new Date(time).toISOString().slice(0, 10)}</text>`,
137
- )
138
- .join("");
139
- const series = lines
140
- .map((line) => {
141
- const path = line.points
142
- .map((p) => `${x(p.time).toFixed(1)},${y(p.median).toFixed(1)}`)
143
- .join(" ");
144
- const dots = line.points
145
- .map(
146
- (p) =>
147
- `<circle cx="${x(p.time).toFixed(1)}" cy="${y(p.median).toFixed(1)}" r="3" style="fill:${line.color}"><title>${escapeHtml(
148
- `${p.date}${p.sha ? ` · ${p.sha.slice(0, 7)}` : ""} · median ${fmtMs(p.median)}${p.p90 != null ? ` · p90 ${fmtMs(p.p90)}` : ""}`,
149
- )}</title></circle>`,
150
- )
151
- .join("");
152
- return `<polyline points="${path}" style="stroke:${line.color}"/>${dots}`;
153
- })
154
- .join("");
155
- return `<svg class="trend" viewBox="0 0 ${width} ${height}" role="img" aria-label="Median over time">${grid}${dateLabels}${series}</svg>`;
156
- }
157
-
158
- function changeText(points) {
159
- const last = points.at(-1);
160
- const before = points.at(-2);
161
- if (last == null || before == null || before.median === 0) return "";
162
- const percent = ((last.median - before.median) / before.median) * 100;
163
- const direction = percent > 0 ? "slower" : "faster";
164
- if (Math.abs(percent) < 0.5) return `<span class="change">no change</span>`;
165
- return `<span class="change ${percent > 0 ? "worse" : "better"}">${Math.abs(percent).toFixed(1)}% ${direction}</span>`;
166
- }
167
-
168
- function card(id, label, contexts) {
169
- const lines = contexts
170
- .map((ctx) => ({
171
- color: ctx.color,
172
- name: machineName(ctx.context),
173
- points: seriesFor(ctx.runs, id),
174
- }))
175
- .filter((line) => line.points.length > 0);
176
- const primary = lines[0];
177
- const latest = primary?.points.at(-1);
178
- return `<article class="card">
179
- <header>
180
- <h3>${escapeHtml(label)}</h3>
181
- <code>${escapeHtml(id)}</code>
182
- </header>
183
- <p class="latest"><strong>${fmtMs(latest?.median)}</strong> median${latest?.p90 != null ? ` · p90 ${fmtMs(latest.p90)}` : ""} ${primary ? changeText(primary.points) : ""}</p>
184
- <p class="when">${latest != null ? `Last run ${escapeHtml(fmtDate(latest.iso))}` : ""}</p>
185
- ${trendSvg(lines)}
186
- </article>`;
187
- }
188
-
189
- function latestTable(contexts, labels, repoUrl) {
190
- const rows = contexts.flatMap((ctx) =>
191
- ctx.runs[0].results.map(
192
- (result) => `<tr>
193
- <td><span class="swatch" style="background:${ctx.color}"></span>${escapeHtml(ctx.context.benchEnv ?? "unknown")}</td>
194
- <td>${escapeHtml(fmtDate(ctx.runs[0].date))}</td>
195
- <td>${commitLink(repoUrl, ctx.runs[0].sha)}</td>
196
- <td>${escapeHtml(labels.get(result.id) ?? result.label)}</td>
197
- <td class="num">${fmtMs(result.median)}</td>
198
- <td class="num">${fmtMs(result.p90)}</td>
199
- <td class="num">${fmtMs(result.min)}</td>
200
- <td class="num">${result.files ?? "–"}</td>
201
- <td class="num">${result.samples}</td>
202
- </tr>`,
203
- ),
204
- );
205
- return `<div class="table-wrap"><table>
206
- <thead><tr><th>Machine</th><th>Run</th><th>Commit</th><th>Benchmark</th><th class="num">Median</th><th class="num">p90</th><th class="num">Min</th><th class="num">Files</th><th class="num">Samples</th></tr></thead>
207
- <tbody>${rows.join("\n")}</tbody>
208
- </table></div>`;
209
- }
210
-
211
- export function buildBenchmarkHtml(
212
- history,
213
- { branch = "local", sha = "local", repoUrl = "https://github.com/typesmiths/decohere" } = {},
214
- ) {
215
- const contexts = activeContexts(history);
216
- const body =
217
- contexts.length === 0
218
- ? `<p class="empty">No benchmark history yet. The nightly bench workflow adds a run each night.</p>`
219
- : (() => {
220
- const labels = benchmarkIds(contexts);
221
- const grouped = GROUPS.map((group) => ({
222
- ...group,
223
- ids: [...labels.keys()].filter((id) => id.startsWith(group.prefix)),
224
- }));
225
- const other = [...labels.keys()].filter(
226
- (id) => !GROUPS.some((group) => id.startsWith(group.prefix)),
227
- );
228
- if (other.length > 0) grouped.push({ title: "Other", ids: other });
229
- const sections = grouped
230
- .filter((group) => group.ids.length > 0)
231
- .map(
232
- (group) => `<section>
233
- <h2>${escapeHtml(group.title)}</h2>
234
- <div class="cards">${group.ids.map((id) => card(id, labels.get(id), contexts)).join("\n")}</div>
235
- </section>`,
236
- )
237
- .join("\n");
238
- const legend = contexts
239
- .map(
240
- (ctx) =>
241
- `<li><span class="swatch" style="background:${ctx.color}"></span>${escapeHtml(machineName(ctx.context))} · ${escapeHtml(ctx.context.os.platform)} ${escapeHtml(ctx.context.os.arch)} · ${ctx.context.memoryGb} GB · Node ${escapeHtml(ctx.context.node)} · ${ctx.runs.length} run(s)</li>`,
242
- )
243
- .join("\n");
244
- return `<section class="machines"><h2>Machines</h2><ul>${legend}</ul>
245
- <p class="note">Each line is one machine type; runs are only compared on the same one. Shared CI runners are noisy, so read the trend, not a single point. Hover a point for its date, commit and p90. Each night also benchmarks the previous nightly's commit on the same runner, alternating ${REGRESSION.rounds} rounds each; a benchmark more than ${Math.round(REGRESSION.threshold * 100)}% slower in every round gets an issue: <a href="${escapeHtml(`${repoUrl}/issues?q=is%3Aissue+is%3Aopen+%22perf(bench)%22`)}">open benchmark issues</a>.</p></section>
246
- ${sections}
247
- <section><h2>Latest run</h2>${latestTable(contexts, labels, repoUrl)}</section>`;
248
- })();
249
- const updated = contexts[0]?.runs[0].date.slice(0, 10) ?? "never";
250
- return `<!doctype html>
251
- <html lang="en">
252
- <head>
253
- <meta charset="utf-8" />
254
- <meta name="viewport" content="width=device-width, initial-scale=1" />
255
- <title>Decohere Benchmarks</title>
256
- <link rel="stylesheet" href="./styles.css" />
257
- </head>
258
- <body>
259
- <main class="container">
260
- <header class="hero">
261
- <h1>Decohere benchmarks</h1>
262
- <p>Updated ${escapeHtml(updated)} · built from ${escapeHtml(branch)} @ <code>${escapeHtml(sha.slice(0, 12))}</code> · <a href="./data/bench-history.json">raw data</a></p>
263
- </header>
264
- ${body}
265
- </main>
266
- </body>
267
- </html>
268
- `;
269
- }
270
-
271
- export function buildStylesCss() {
272
- return `:root {
273
- --bg: #f6f7f9; --surface: #ffffff; --text: #1b1f24; --muted: #59636e; --border: #d8dee4;
274
- --accent: #0b6e4f; --better: #1a7f37; --worse: #cf222e;
275
- --series-1: #0b6e4f; --series-2: #8250df; --series-3: #bc4c00; --series-4: #0969da;
276
- color-scheme: light dark;
277
- }
278
- @media (prefers-color-scheme: dark) {
279
- :root:not([data-theme="light"]) {
280
- --bg: #0d1117; --surface: #161b22; --text: #e6edf3; --muted: #9198a1; --border: #30363d;
281
- --accent: #3fb68b; --better: #3fb950; --worse: #f85149;
282
- --series-1: #3fb68b; --series-2: #a371f7; --series-3: #f0883e; --series-4: #4493f8;
283
- }
284
- }
285
- :root[data-theme="dark"] {
286
- --bg: #0d1117; --surface: #161b22; --text: #e6edf3; --muted: #9198a1; --border: #30363d;
287
- --accent: #3fb68b; --better: #3fb950; --worse: #f85149;
288
- --series-1: #3fb68b; --series-2: #a371f7; --series-3: #f0883e; --series-4: #4493f8;
289
- }
290
- * { box-sizing: border-box; }
291
- body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
292
- a { color: var(--accent); }
293
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85em; }
294
- .container { max-width: 1180px; margin: 0 auto; padding: 24px 16px 64px; }
295
- .hero h1 { margin: 0 0 4px; font-size: 1.6rem; }
296
- .hero p, .note, .empty { color: var(--muted); margin: 0; }
297
- section { margin-top: 32px; }
298
- h2 { font-size: 1.1rem; margin: 0 0 12px; }
299
- .machines ul { list-style: none; padding: 0; margin: 0 0 8px; }
300
- .machines li { margin: 4px 0; }
301
- .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 8px; vertical-align: baseline; }
302
- .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr)); gap: 16px; }
303
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; min-width: 0; }
304
- .card header { display: flex; flex-direction: column; gap: 2px; }
305
- .card h3 { font-size: 0.95rem; margin: 0; }
306
- .card header code { color: var(--muted); }
307
- .latest { margin: 8px 0 0; }
308
- .when { margin: 0 0 4px; color: var(--muted); font-size: 0.85rem; }
309
- .latest strong { font-size: 1.25rem; font-variant-numeric: tabular-nums; }
310
- .change { margin-left: 6px; font-size: 0.85rem; color: var(--muted); }
311
- .change.better { color: var(--better); }
312
- .change.worse { color: var(--worse); }
313
- .trend { width: 100%; height: auto; display: block; }
314
- .trend polyline { fill: none; stroke-width: 2; }
315
- .trend .grid { stroke: var(--border); stroke-width: 1; }
316
- .trend .axis { fill: var(--muted); font-size: 11px; }
317
- .table-wrap { overflow-x: auto; }
318
- table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; font-variant-numeric: tabular-nums; }
319
- th, td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
320
- th { color: var(--muted); font-weight: 600; }
321
- .num { text-align: right; }
322
- `;
323
- }
@@ -1,58 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // Restore the badges on the live Pages site into dist, so a deploy that does not regenerate
4
- // them (the nightly bench) keeps them. Pages replaces the whole site on every deploy.
5
- //
6
- // The site lists its badges in badges/manifest.json (artifact.mjs). Sites deployed before the
7
- // manifest existed are read with a fixed list instead. A badge the site does not have (404) is
8
- // skipped; any other failure stops the job.
9
-
10
- import { mkdir, writeFile } from "node:fs/promises";
11
- import path from "node:path";
12
-
13
- const BADGES_DIR = "projects/ci-tools/dist/ci/badges";
14
- const PAGES_URL = process.env.PAGES_URL ?? "https://typesmiths.github.io/decohere";
15
-
16
- const LEGACY_BADGES = [
17
- "ci-main.svg",
18
- "ci-release.svg",
19
- "coverage.svg",
20
- ...[
21
- "ci-tools",
22
- "cli",
23
- "codex",
24
- "cortex",
25
- "create",
26
- "crucible",
27
- "flux",
28
- "forge",
29
- "mesh",
30
- "nexus",
31
- "protocol",
32
- "scribe",
33
- "terminal",
34
- ].map((project) => `projects/${project}.svg`),
35
- ];
36
-
37
- async function fetchOrNull(url) {
38
- const response = await fetch(url);
39
- if (response.status === 404) return null;
40
- if (!response.ok) throw new Error(`GET ${url}: HTTP ${response.status}`);
41
- return response;
42
- }
43
-
44
- const manifest = await fetchOrNull(`${PAGES_URL}/badges/manifest.json`);
45
- const badges = manifest != null ? (await manifest.json()).badges : LEGACY_BADGES;
46
-
47
- let restored = 0;
48
- for (const badge of badges) {
49
- const response = await fetchOrNull(`${PAGES_URL}/badges/${badge}`);
50
- if (response == null) continue;
51
- const file = path.join(BADGES_DIR, badge);
52
- await mkdir(path.dirname(file), { recursive: true });
53
- await writeFile(file, await response.text());
54
- restored += 1;
55
- }
56
- console.log(
57
- `✓ Restored ${restored} badge(s) from Pages (${manifest != null ? "manifest" : "legacy list"})`,
58
- );
@@ -1,115 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- formatLog,
4
- formatMarkdown,
5
- highCritical,
6
- parseAudit,
7
- } from "../../src/audit/format.mjs";
8
-
9
- const fixture = {
10
- metadata: {
11
- vulnerabilities: { info: 0, low: 1, moderate: 0, high: 1, critical: 0, total: 2 },
12
- },
13
- advisories: {
14
- 1: {
15
- id: 1,
16
- module_name: "nanoid",
17
- severity: "high",
18
- title: "nanoid: custom generators can loop indefinitely when size is zero",
19
- url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
20
- vulnerable_versions: "<3.3.18",
21
- patched_versions: ">=3.3.18",
22
- cves: [],
23
- findings: [{ version: "3.3.17", paths: [". > vite > postcss > nanoid@3.3.17"] }],
24
- },
25
- 2: {
26
- id: 2,
27
- module_name: "esbuild",
28
- severity: "low",
29
- title: "esbuild Windows file read",
30
- url: "https://github.com/advisories/GHSA-g7r4-m6w7-qqqr",
31
- vulnerable_versions: "<0.28.1",
32
- patched_versions: ">=0.28.1",
33
- cves: [],
34
- findings: [{ version: "0.27.3", paths: [". > vite > esbuild"] }],
35
- },
36
- },
37
- };
38
-
39
- describe("parseAudit", () => {
40
- it("reads severity counts and sorts advisories critical/high first", () => {
41
- const parsed = parseAudit(fixture);
42
- expect(parsed.total).toBe(2);
43
- expect(parsed.high).toBe(1);
44
- expect(parsed.low).toBe(1);
45
- expect(parsed.advisories.map((a) => a.module)).toEqual(["nanoid", "esbuild"]);
46
- expect(parsed.advisories[0]?.severity).toBe("high");
47
- expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
48
- });
49
- });
50
-
51
- describe("formatLog", () => {
52
- it("prints severity counts and advisory lines including GHSA", () => {
53
- const log = formatLog(parseAudit(fixture));
54
- expect(log).toContain("high=1");
55
- expect(log).toContain("[high] nanoid GHSA-2v37-7h3g-55p8");
56
- expect(log).toContain("[low] esbuild");
57
- });
58
- });
59
-
60
- describe("formatMarkdown", () => {
61
- it("includes a severity table and linked advisories", () => {
62
- const md = formatMarkdown(parseAudit(fixture));
63
- expect(md).toContain("| High | 1 |");
64
- expect(md).toContain("**high** `nanoid`");
65
- expect(md).toContain("GHSA-2v37-7h3g-55p8");
66
- });
67
- });
68
-
69
- describe("highCritical", () => {
70
- it("filters to high and critical only", () => {
71
- expect(highCritical(parseAudit(fixture)).map((a) => a.module)).toEqual(["nanoid"]);
72
- });
73
- });
74
-
75
- describe("parseAudit npm v2", () => {
76
- it("dedupes GHSA rows from package via objects", () => {
77
- const v2 = {
78
- auditReportVersion: 2,
79
- metadata: {
80
- vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0, total: 1 },
81
- },
82
- vulnerabilities: {
83
- nanoid: {
84
- name: "nanoid",
85
- severity: "high",
86
- via: [
87
- {
88
- source: 1103848,
89
- name: "nanoid",
90
- title: "nanoid infinite loop",
91
- url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
92
- severity: "high",
93
- range: "<3.3.18",
94
- },
95
- ],
96
- range: "<3.3.18",
97
- nodes: ["node_modules/.pnpm/nanoid@3.3.17/node_modules/nanoid"],
98
- },
99
- postcss: {
100
- name: "postcss",
101
- severity: "high",
102
- via: ["nanoid"],
103
- effects: [],
104
- range: "*",
105
- nodes: ["node_modules/.pnpm/postcss@8.5.26/node_modules/postcss"],
106
- },
107
- },
108
- };
109
- const parsed = parseAudit(v2);
110
- expect(parsed.advisories).toHaveLength(1);
111
- expect(parsed.advisories[0]?.module).toBe("nanoid");
112
- expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
113
- expect(parsed.advisories[0]?.vulnerable).toBe("<3.3.18");
114
- });
115
- });