@decohere/ci-tools 0.0.1-dev.118 → 0.0.1-dev.12

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 (36) hide show
  1. package/package.json +3 -8
  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/src/audit/format.mjs +0 -133
  16. package/src/bench/file-regressions.mjs +0 -76
  17. package/src/bench/history.mjs +0 -248
  18. package/src/bench/nightly.mjs +0 -118
  19. package/src/bench/regression-issue.mjs +0 -44
  20. package/src/github/credential-nudge.mjs +0 -245
  21. package/src/github/oidc-bootstrap-nudge.mjs +0 -210
  22. package/src/github/publish-verify.mjs +0 -206
  23. package/src/github/smoke-create-launcher.mjs +0 -70
  24. package/src/lib/github-issue.mjs +0 -75
  25. package/src/lib/workspace-packages.mjs +0 -30
  26. package/src/pages/bench-page.mjs +0 -323
  27. package/src/pages/restore-badges.mjs +0 -58
  28. package/tests/audit/format.test.mjs +0 -115
  29. package/tests/bench/history.test.mjs +0 -279
  30. package/tests/bench/regression-issue.test.mjs +0 -42
  31. package/tests/github/credential-nudge.test.mjs +0 -116
  32. package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
  33. package/tests/github/publish-verify.test.mjs +0 -115
  34. package/tests/github/smoke-create-launcher.test.mjs +0 -71
  35. package/tests/lib/github-issue.test.mjs +0 -85
  36. package/tests/pages/bench-page.test.mjs +0 -123
@@ -1,70 +0,0 @@
1
- #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
3
- import { mkdtempSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join, resolve } from "node:path";
6
- import { pathToFileURL } from "node:url";
7
-
8
- /**
9
- * Run `npx @decohere/create@version --help` from an empty temp directory.
10
- * Must not use the repo cwd — after `@decohere/cli` is pinned, pnpm installs
11
- * `@decohere/create` into the workspace tree and npm's npx misses the bin.
12
- *
13
- * @param {{
14
- * createVersion: string,
15
- * authToken: string,
16
- * mkdtempSync?: typeof mkdtempSync,
17
- * writeFileSync?: typeof writeFileSync,
18
- * spawnSync?: typeof spawnSync,
19
- * tmpdir?: () => string,
20
- * stdio?: import("node:child_process").StdioOptions,
21
- * }} options
22
- */
23
- export function runCreateLauncherSmoke(options) {
24
- const version = options.createVersion?.trim() ?? "";
25
- const token = options.authToken?.trim() ?? "";
26
- if (!version) {
27
- throw new Error("CREATE_VERSION is required");
28
- }
29
- if (!token) {
30
- throw new Error("NODE_AUTH_TOKEN is required");
31
- }
32
-
33
- const mkdtemp = options.mkdtempSync ?? mkdtempSync;
34
- const writeFile = options.writeFileSync ?? writeFileSync;
35
- const spawn = options.spawnSync ?? spawnSync;
36
- const tmp = options.tmpdir ?? tmpdir;
37
-
38
- const workDir = mkdtemp(join(tmp(), "decohere-create-smoke-"));
39
- const npmrc = join(workDir, ".npmrc");
40
- writeFile(
41
- npmrc,
42
- `@decohere:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=${token}\n`,
43
- );
44
-
45
- const result = spawn("npx", ["--yes", `@decohere/create@${version}`, "--help"], {
46
- cwd: workDir,
47
- env: {
48
- ...process.env,
49
- NPM_CONFIG_USERCONFIG: npmrc,
50
- NODE_AUTH_TOKEN: token,
51
- },
52
- stdio: options.stdio ?? "inherit",
53
- });
54
-
55
- return {
56
- workDir,
57
- status: result.status ?? 1,
58
- };
59
- }
60
-
61
- const isCli =
62
- process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
63
-
64
- if (isCli) {
65
- const { status } = runCreateLauncherSmoke({
66
- createVersion: process.env.CREATE_VERSION ?? "",
67
- authToken: process.env.NODE_AUTH_TOKEN ?? "",
68
- });
69
- process.exit(status);
70
- }
@@ -1,75 +0,0 @@
1
- /**
2
- * One GitHub issue per subject, found again by its exact title and updated in place: CI files
3
- * a reminder once and keeps it current instead of opening a new issue every run. Needs `gh` and
4
- * GH_TOKEN (or GITHUB_TOKEN) with issues: write.
5
- */
6
-
7
- import { spawnSync } from "node:child_process";
8
-
9
- /**
10
- * @param {string[]} args
11
- * @param {string} [input]
12
- */
13
- export function gh(args, input) {
14
- const result = spawnSync("gh", args, { encoding: "utf8", input, env: process.env });
15
- if (result.status !== 0) {
16
- throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
17
- }
18
- return result.stdout;
19
- }
20
-
21
- /**
22
- * The issue titled exactly `title`, preferring an open one over a closed one.
23
- *
24
- * @param {{ number: number, title: string, state: string, url: string }[]} issues
25
- * @param {string} title
26
- */
27
- export function pickIssue(issues, title) {
28
- const matches = issues.filter((issue) => issue.title === title);
29
- return matches.find((issue) => issue.state === "OPEN") ?? matches[0] ?? null;
30
- }
31
-
32
- /**
33
- * @param {{ title: string, body: string, assignees?: string[], repo?: string }} options
34
- * @param {typeof gh} [run]
35
- * @returns {{ number: number, url: string, created: boolean }}
36
- */
37
- export function upsertIssue(options, run = gh) {
38
- const { title, body, assignees = [] } = options;
39
- const repo = options.repo ? ["--repo", options.repo] : [];
40
- const issues = JSON.parse(
41
- run([
42
- "issue",
43
- "list",
44
- ...repo,
45
- "--state",
46
- "all",
47
- "--limit",
48
- "200",
49
- "--search",
50
- // Quoted: GitHub's search can't match a title like `security(ci): …` unquoted (#681).
51
- // The exact-title match in pickIssue still decides.
52
- `"${title.replaceAll('"', "")}" in:title`,
53
- "--json",
54
- "number,title,state,url",
55
- ]),
56
- );
57
- const assign = assignees.flatMap((login) => ["--add-assignee", login]);
58
- const existing = pickIssue(issues, title);
59
- if (existing != null) {
60
- if (existing.state !== "OPEN") {
61
- run(["issue", "reopen", String(existing.number), ...repo]);
62
- }
63
- run(
64
- ["issue", "edit", String(existing.number), ...repo, "--body-file", "-", ...assign],
65
- body,
66
- );
67
- return { number: existing.number, url: existing.url, created: false };
68
- }
69
- const url = run(
70
- ["issue", "create", ...repo, "--title", title, "--body-file", "-", ...assign],
71
- body,
72
- ).trim();
73
- const number = Number(url.split("/").pop());
74
- return { number, url, created: true };
75
- }
@@ -1,30 +0,0 @@
1
- import { existsSync, readdirSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
- // The same rule as WorkspaceDefaults in @decohere/workspace (`projects/**/package.json`, minus these
5
- // folders, and a package.json inside another project is not a project). CI scripts run on plain
6
- // Node before any install, so they can't import the workspace package itself.
7
- const SKIP = new Set(["node_modules", "__fixtures__", "dist"]);
8
-
9
- /**
10
- * Every project directory under `<root>/projects`, at any depth (`projects/cli`,
11
- * `projects/data/data-typeorm`).
12
- * @param {string} root
13
- * @returns {string[]} absolute paths
14
- */
15
- export function listProjectDirs(root) {
16
- const found = [];
17
- const walk = (dir) => {
18
- if (existsSync(join(dir, "package.json"))) {
19
- found.push(dir);
20
- return;
21
- }
22
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
23
- if (entry.isDirectory() && !SKIP.has(entry.name) && !entry.name.startsWith(".")) {
24
- walk(join(dir, entry.name));
25
- }
26
- }
27
- };
28
- walk(join(root, "projects"));
29
- return found;
30
- }
@@ -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("&", "&amp;")
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
- });