tosijs-ui 1.7.3 → 1.8.0

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/CHANGELOG.md +89 -0
  2. package/dist/doc-browser.d.ts +11 -0
  3. package/dist/doc-browser.js +63 -7
  4. package/dist/doc-system/doc-system.js +1 -0
  5. package/dist/doc-system/nav-tree.d.ts +24 -5
  6. package/dist/doc-system/nav-tree.js +0 -0
  7. package/dist/doc-system/site/audit-guard.d.ts +124 -0
  8. package/dist/doc-system/site/audit-guard.js +555 -0
  9. package/dist/doc-system/site/dev-server.js +59 -3
  10. package/dist/doc-system/site/generate-site.d.ts +2 -0
  11. package/dist/doc-system/site/generate-site.js +1 -0
  12. package/dist/doc-system/site/index.d.ts +4 -0
  13. package/dist/doc-system/site/index.js +2 -0
  14. package/dist/doc-system/site/open-browser.d.ts +53 -0
  15. package/dist/doc-system/site/open-browser.js +231 -0
  16. package/dist/doc-system/site/orchestrator.d.ts +3 -1
  17. package/dist/doc-system/site/orchestrator.js +14 -1
  18. package/dist/doc-system/site/site-config.d.ts +48 -0
  19. package/dist/hydrate/{ajs-language-qm5w38ae.js → ajs-language-xvpytqfm.js} +2 -2
  20. package/dist/hydrate/{code-editor-cm-95n31m4f.js → code-editor-cm-59je4d8g.js} +6 -6
  21. package/dist/hydrate/{code-editor-cm-95n31m4f.js.map → code-editor-cm-59je4d8g.js.map} +4 -4
  22. package/dist/hydrate/hydrate.js +52 -52
  23. package/dist/hydrate/hydrate.js.map +6 -6
  24. package/dist/hydrate/index-iife-2q1gndgz.js +27 -0
  25. package/dist/hydrate/{index-iife-kwhe0yt8.js.map → index-iife-2q1gndgz.js.map} +3 -3
  26. package/dist/hydrate/{index-iife-ype8sch3.js → index-iife-xv69gy4m.js} +6 -6
  27. package/dist/hydrate/index-iife-xv69gy4m.js.map +12 -0
  28. package/dist/hydrate/{index-0ps8jzdn.js → index-vk8ek4jm.js} +2 -2
  29. package/dist/iife.js +75 -75
  30. package/dist/iife.js.map +10 -10
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/llms.txt +1 -1
  35. package/package.json +17 -13
  36. package/tls/create-dev-certs.sh +1 -1
  37. package/dist/hydrate/index-iife-kwhe0yt8.js +0 -27
  38. package/dist/hydrate/index-iife-ype8sch3.js.map +0 -12
  39. /package/dist/hydrate/{ajs-language-qm5w38ae.js.map → ajs-language-xvpytqfm.js.map} +0 -0
  40. /package/dist/hydrate/{index-0ps8jzdn.js.map → index-vk8ek4jm.js.map} +0 -0
@@ -0,0 +1,555 @@
1
+ /*
2
+ Dependency-audit gate for the doc-site build and dev server.
3
+
4
+ `bun audit` knows the registry advisory database; nothing in this build ever
5
+ asked it. So ask it, once, at the point a human is looking: the initial build
6
+ and the dev-server launch. A high-or-worse advisory FAILS the build — unless the
7
+ specific advisory is explicitly gated with a reason AND an expiry date, which
8
+ forces it back onto the table instead of being silenced forever.
9
+
10
+ Design notes that mirror `preflight.ts`:
11
+
12
+ - Returns a result; NEVER `process.exit`. This is reachable through the public
13
+ `tosijs-ui/site` export, and library code does not get to kill a caller's
14
+ process from inside a health check. `buildSite` turns a blocking result into a
15
+ failed build; the dev server (which by then owns a running process on which the
16
+ finding is the whole point) is the one place that self-terminates — same
17
+ precedent as the memory watchdog.
18
+ - Fail-OPEN on inability to check (offline, registry down, `bun` too old): a
19
+ dependency advisory you couldn't fetch must not ground you on a plane. Fail-
20
+ CLOSED on an actual finding.
21
+ - NOT downgraded in CI. Unlike the machine-health preflight — a heuristic about
22
+ someone's local box — an advisory is deterministic and environment-independent,
23
+ so CI is exactly where you want the gate to bite.
24
+
25
+ `bun audit` is itself a subprocess, so shelling out already satisfies the "never a
26
+ native-heavy API in a long-lived process" rule. Build-time only (Bun/Node APIs);
27
+ never import this from browser code.
28
+ */
29
+ import { $ } from 'bun';
30
+ const SEVERITY_RANK = {
31
+ info: 0,
32
+ low: 1,
33
+ moderate: 2,
34
+ high: 3,
35
+ critical: 4,
36
+ };
37
+ /*
38
+ CWEs that routinely escalate beyond what the vector admits. Prototype pollution is
39
+ the canonical case and it is not hypothetical: in the sample set, protobufjs had an
40
+ advisory scored A:H (availability-only) tagged CWE-1321, AND a separate C:H/I:H
41
+ advisory titled "code generation gadget AFTER prototype pollution" — the escalation
42
+ chain the first vector does not encode. A vector-only reading calls that benign.
43
+ */
44
+ const ESCALATABLE_CWE = new Set(['1321', '915', '502', '94', '78', '77']);
45
+ function parseVector(v) {
46
+ if (!v)
47
+ return null;
48
+ const out = {};
49
+ let version = '';
50
+ for (const part of v.split('/')) {
51
+ const [k, val] = part.split(':');
52
+ if (!k || !val)
53
+ continue;
54
+ if (k === 'CVSS')
55
+ version = val;
56
+ else
57
+ out[k] = val;
58
+ }
59
+ if (!Object.keys(out).length)
60
+ return null;
61
+ out.__version = version || '?';
62
+ return out;
63
+ }
64
+ /** Classify one advisory's nature from its CVSS vector + CWEs. Fails CLOSED. */
65
+ export function classifyRisk(raw) {
66
+ const vec = parseVector(raw.cvss?.vectorString ?? '');
67
+ const cwes = (raw.cwe ?? []).map((c) => String(c).replace(/^CWE-/i, ''));
68
+ const escalatable = cwes.some((c) => ESCALATABLE_CWE.has(c));
69
+ if (!vec) {
70
+ return {
71
+ nature: 'unknown',
72
+ label: 'UNCLASSIFIED',
73
+ basis: 'no CVSS vector in advisory — treat as worst case',
74
+ };
75
+ }
76
+ // CVSS 4.0 renamed the impact metrics: VC/VI/VA (vulnerable system) and
77
+ // SC/SI/SA (subsequent system). 2.0/3.x use plain C/I/A. Support both; any
78
+ // other shape is unknown rather than assumed benign.
79
+ const v = vec.__version;
80
+ const isV4 = v.startsWith('4');
81
+ const conf = isV4 ? vec.VC ?? vec.SC : vec.C;
82
+ const integ = isV4 ? vec.VI ?? vec.SI : vec.I;
83
+ const avail = isV4 ? vec.VA ?? vec.SA : vec.A;
84
+ if (conf === undefined || integ === undefined || avail === undefined) {
85
+ return {
86
+ nature: 'unknown',
87
+ label: 'UNCLASSIFIED',
88
+ basis: `CVSS ${v} vector missing impact metrics — treat as worst case`,
89
+ };
90
+ }
91
+ const hit = (x) => x === 'L' || x === 'H';
92
+ if (hit(conf) || hit(integ)) {
93
+ return {
94
+ nature: 'compromise',
95
+ label: 'LEAK/ALTER',
96
+ basis: `CVSS ${v} C:${conf} I:${integ}`,
97
+ };
98
+ }
99
+ if (escalatable) {
100
+ return {
101
+ nature: 'unknown',
102
+ label: 'DoS?+ESCALATABLE',
103
+ basis: `CVSS ${v} A:${avail} only, but CWE-${cwes.join('/')} can escalate`,
104
+ };
105
+ }
106
+ return {
107
+ nature: 'dos',
108
+ label: 'DoS-only',
109
+ basis: `CVSS ${v} C:N I:N A:${avail}`,
110
+ };
111
+ }
112
+ /*
113
+ The audit is FAST — sub-second even on a large tree, because the dependency
114
+ resolution is local and it is one registry round-trip. That is why it runs
115
+ synchronously everywhere (see buildSite / devServer): a gate you wait for is a gate
116
+ that cannot be raced, and it removes a whole class of "it printed after I'd already
117
+ started working" edge cases.
118
+
119
+ What sync DOES introduce is a hang: a captive portal, a VPN coming up, or a
120
+ registry black-holing the connection makes a fetch that never returns, and a build
121
+ that hangs forever is worse than one that skips a check. So bound it — on timeout
122
+ we fail OPEN (same as offline), because an advisory we could not fetch must not
123
+ ground someone on a plane.
124
+ */
125
+ export const AUDIT_TIMEOUT_MS = 20_000;
126
+ /*
127
+ The runner MUST read the exit code. Measured on bun 1.3.14:
128
+
129
+ clean tree → exit 0, stdout `{}` (3 bytes — never empty)
130
+ advisories found → exit 1, stdout `{...}`
131
+ no lockfile → exit 1, stdout EMPTY, stderr `error: Lockfile not found`
132
+ offline / registry
133
+ refused / old bun → exit 1, stdout EMPTY
134
+
135
+ so **empty stdout means the audit FAILED — it never means "clean"**. An earlier
136
+ version returned stdout blind and let `parseAuditJson('')` answer `[]`, which the
137
+ caller read as `ran: true, ok: true` and printed "✅ dependency audit clean" — a
138
+ green checkmark for a check that never contacted the registry, on every build,
139
+ forever, for anyone behind a proxy or on a non-bun lockfile. A security gate that
140
+ reports a pass it did not earn is worse than no gate: it converts an unknown into a
141
+ false assurance. Exit code first, then content.
142
+ */
143
+ const defaultRunner = async () => {
144
+ const proc = $ `bun audit --json`.nothrow().quiet();
145
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(null), AUDIT_TIMEOUT_MS).unref?.());
146
+ const r = await Promise.race([proc, timeout]);
147
+ if (r === null)
148
+ throw new Error('bun audit timed out');
149
+ const stdout = r.stdout.toString();
150
+ // 0 = clean, 1 = advisories found. Both print JSON. Anything else — or a
151
+ // "success" with no output at all — is a failed run, not a clean one: throw so
152
+ // the caller takes the honest fail-OPEN path (ran:false) and warns.
153
+ if (r.exitCode !== 0 && r.exitCode !== 1) {
154
+ throw new Error(`bun audit exited ${r.exitCode}: ${r.stderr.toString().trim().slice(0, 300)}`);
155
+ }
156
+ if (stdout.trim() === '') {
157
+ throw new Error(`bun audit produced no output (exit ${r.exitCode}): ` +
158
+ `${r.stderr.toString().trim().slice(0, 300) || 'no stderr'}`);
159
+ }
160
+ return stdout;
161
+ };
162
+ /** Extract a GHSA id from an advisory URL, if the URL carries one. */
163
+ function parseGhsa(url) {
164
+ const m = url?.match(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i);
165
+ return m ? m[0] : undefined;
166
+ }
167
+ /**
168
+ * Parse `bun audit --json` output — `{ "<pkg>": [advisory, …] }` — into a flat
169
+ * advisory list. Returns null when the text is not a JSON object (offline, an
170
+ * error dump, an incompatible bun): the caller reads null as "couldn't check".
171
+ */
172
+ export function parseAuditJson(text) {
173
+ const trimmed = text.trim();
174
+ // Empty is NOT "clean" — a clean tree prints `{}`. Empty stdout only happens
175
+ // when the audit failed (no lockfile, offline, registry refused, bun too old),
176
+ // so answering `[]` here manufactured a green result out of a failed check.
177
+ if (trimmed === '')
178
+ return null;
179
+ let data;
180
+ try {
181
+ data = JSON.parse(trimmed);
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ if (data === null || typeof data !== 'object' || Array.isArray(data))
187
+ return null;
188
+ const out = [];
189
+ for (const [pkg, advisories] of Object.entries(data)) {
190
+ if (!Array.isArray(advisories))
191
+ continue;
192
+ for (const a of advisories) {
193
+ if (!a || typeof a !== 'object')
194
+ continue;
195
+ const adv = a;
196
+ const severity = String(adv.severity ?? 'info').toLowerCase();
197
+ out.push({
198
+ package: pkg,
199
+ id: Number(adv.id) || 0,
200
+ url: String(adv.url ?? ''),
201
+ title: String(adv.title ?? ''),
202
+ severity: severity in SEVERITY_RANK ? severity : 'info',
203
+ vulnerableVersions: adv.vulnerable_versions != null
204
+ ? String(adv.vulnerable_versions)
205
+ : undefined,
206
+ ghsa: parseGhsa(String(adv.url ?? '')),
207
+ risk: classifyRisk({
208
+ cvss: adv.cvss,
209
+ cwe: Array.isArray(adv.cwe) ? adv.cwe : null,
210
+ }),
211
+ });
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+ /** yyyy-mm-dd for a Date, in UTC (so gate expiry is timezone-stable). */
217
+ function isoDay(d) {
218
+ return d.toISOString().slice(0, 10);
219
+ }
220
+ function daysBetween(fromIso, toIso) {
221
+ const a = Date.parse(fromIso + 'T00:00:00Z');
222
+ const b = Date.parse(toIso + 'T00:00:00Z');
223
+ return Math.round((b - a) / 86400000);
224
+ }
225
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
226
+ /** Does a gate refer to this advisory? By GHSA, numeric id, or package name. */
227
+ function gateMatches(gate, adv) {
228
+ const ref = gate.advisory?.trim();
229
+ if (!ref)
230
+ return false;
231
+ if (adv.ghsa && ref.toLowerCase() === adv.ghsa.toLowerCase())
232
+ return true;
233
+ if (adv.id && ref === String(adv.id))
234
+ return true;
235
+ if (ref === adv.package)
236
+ return true;
237
+ return false;
238
+ }
239
+ /** Resolve the effective mode from config + the TOSIJS_AUDIT env override. */
240
+ export function resolveAuditMode(config) {
241
+ const env = process.env.TOSIJS_AUDIT?.toLowerCase();
242
+ if (env === 'off' || env === 'warn' || env === 'fail')
243
+ return env;
244
+ if (config === false)
245
+ return 'off';
246
+ if (config === true || config === undefined)
247
+ return 'fail'; // on by default
248
+ return config.mode ?? 'fail';
249
+ }
250
+ /*
251
+ "Audit ONCE per process, never on a watch rebuild" is the real invariant, and it
252
+ used to be enforced by three separate call sites remembering to pass `skipAudit`
253
+ — which means it was owned by nobody. The documented adopter pattern already broke
254
+ it: `doc-site-system.md` tells you to hand `devServer` a `{ build }` that calls
255
+ `buildSite(config)`, with no `skipAudit`, so every keystroke rebuild fired a
256
+ registry round-trip — a network call in the edit loop, and offline dev broken, for
257
+ anyone who followed the docs.
258
+
259
+ Own it here instead. The first real audit in a process is remembered and every
260
+ later call returns it, so the invariant holds no matter how many callers there are
261
+ or which of them forgot a flag. `skipAudit` still short-circuits earlier (it avoids
262
+ even the first call), and an injected `runAudit` always re-runs — tests need to
263
+ drive many scenarios in one process, and memoizing those would make them lie.
264
+ */
265
+ let processAudit = null;
266
+ /** Test-only: forget the per-process audit memo. */
267
+ export function resetAuditMemo() {
268
+ processAudit = null;
269
+ }
270
+ /** Remember a real (non-injected) audit so later callers reuse it. */
271
+ function memo(result, injected) {
272
+ if (!injected)
273
+ processAudit = result;
274
+ return result;
275
+ }
276
+ /**
277
+ * Run `bun audit`, classify findings against the configured threshold and the
278
+ * time-boxed allowlist, and return a structured verdict. Never exits.
279
+ *
280
+ * Memoized per process (see above) unless `opts.runAudit` is injected.
281
+ */
282
+ export async function auditDependencies(config, opts = {}) {
283
+ if (!opts.runAudit && processAudit)
284
+ return processAudit;
285
+ const mode = resolveAuditMode(config);
286
+ const cfg = config && typeof config === 'object' ? config : {};
287
+ const level = cfg.level ?? 'high';
288
+ const now = opts.now ?? new Date();
289
+ const today = isoDay(now);
290
+ const base = {
291
+ ran: false,
292
+ ok: true,
293
+ mode,
294
+ level,
295
+ blocking: [],
296
+ gated: [],
297
+ expired: [],
298
+ invalid: [],
299
+ stale: [],
300
+ belowThreshold: [],
301
+ };
302
+ // Not memoized: nothing ran, so there is no result to reuse.
303
+ if (mode === 'off')
304
+ return base;
305
+ let text;
306
+ try {
307
+ text = await (opts.runAudit ?? defaultRunner)();
308
+ }
309
+ catch {
310
+ return memo(base, !!opts.runAudit); // couldn't even spawn — fail open
311
+ }
312
+ const advisories = parseAuditJson(text);
313
+ // Unparseable/empty => couldn't check. Memoized so a watch session doesn't
314
+ // retry the registry on every rebuild while offline.
315
+ if (advisories === null)
316
+ return memo(base, !!opts.runAudit);
317
+ const result = { ...base, ran: true };
318
+ const threshold = SEVERITY_RANK[level];
319
+ // Validate gates once; an invalid gate (no reason / no valid date) never
320
+ // suppresses — that is what "explicitly and specifically gated" means.
321
+ const gates = cfg.allow ?? [];
322
+ const validGates = [];
323
+ for (const gate of gates) {
324
+ const problems = [];
325
+ if (!gate.advisory?.trim())
326
+ problems.push('no advisory id/package');
327
+ if (!gate.reason?.trim())
328
+ problems.push('no reason');
329
+ if (!gate.expires?.trim() || !DATE_RE.test(gate.expires.trim())) {
330
+ problems.push('no valid expires (YYYY-MM-DD)');
331
+ }
332
+ else if (Number.isNaN(Date.parse(gate.expires.trim() + 'T00:00:00Z'))) {
333
+ problems.push('unparseable expires date');
334
+ }
335
+ if (problems.length) {
336
+ result.invalid.push({ gate, problem: problems.join('; ') });
337
+ }
338
+ else {
339
+ validGates.push(gate);
340
+ }
341
+ }
342
+ const usedGates = new Set();
343
+ for (const adv of advisories) {
344
+ if (SEVERITY_RANK[adv.severity] < threshold) {
345
+ result.belowThreshold.push(adv);
346
+ continue;
347
+ }
348
+ const gate = validGates.find((g) => gateMatches(g, adv));
349
+ if (!gate) {
350
+ result.blocking.push(adv);
351
+ continue;
352
+ }
353
+ usedGates.add(gate);
354
+ const expires = gate.expires.trim();
355
+ if (today >= expires) {
356
+ // Gate has expired (inclusive of the expiry day) — re-evaluate; it blocks.
357
+ result.expired.push({
358
+ advisory: adv,
359
+ reason: gate.reason,
360
+ daysAgo: daysBetween(expires, today),
361
+ });
362
+ result.blocking.push(adv);
363
+ }
364
+ else {
365
+ result.gated.push({
366
+ advisory: adv,
367
+ reason: gate.reason,
368
+ daysLeft: daysBetween(today, expires),
369
+ });
370
+ }
371
+ }
372
+ // Valid gates that suppressed nothing this run — the advisory is gone; delete them.
373
+ result.stale = validGates.filter((g) => !usedGates.has(g));
374
+ result.ok = result.blocking.length === 0;
375
+ return memo(result, !!opts.runAudit);
376
+ }
377
+ const DUE_DILIGENCE = [
378
+ 'Before adopting a patch:',
379
+ ' • Read the advisory. Confirm the patched version genuinely fixes it and is',
380
+ ' published by the package’s real maintainers (guard against hijacked releases).',
381
+ ' • Prefer the MINIMAL fix — a targeted `overrides`/`resolutions` pin to the',
382
+ ' patched version — over a broad `bun update --latest` that churns dozens of',
383
+ ' transitive deps, each a fresh supply-chain surface.',
384
+ ' • Treat large churn in a "patch" as itself suspicious; review what moved.',
385
+ ' • If you can’t patch now, GATE it (reason + near-term expiry) — don’t silence it.',
386
+ ].join('\n');
387
+ export function groupAdvisories(advisories) {
388
+ const groups = new Map();
389
+ for (const adv of advisories) {
390
+ // Same package AND same advisory → one entry. Different packages stay
391
+ // separate even under a shared advisory id (they're genuinely different fixes).
392
+ const key = `${adv.package}::${adv.ghsa ?? adv.id}`;
393
+ const existing = groups.get(key);
394
+ if (existing) {
395
+ if (adv.vulnerableVersions && !existing.ranges.includes(adv.vulnerableVersions)) {
396
+ existing.ranges.push(adv.vulnerableVersions);
397
+ }
398
+ }
399
+ else {
400
+ groups.set(key, {
401
+ advisory: adv,
402
+ ranges: adv.vulnerableVersions ? [adv.vulnerableVersions] : [],
403
+ });
404
+ }
405
+ }
406
+ return Array.from(groups.values());
407
+ }
408
+ /*
409
+ Order by what a reader must act on FIRST. Severity descending, then by nature —
410
+ `compromise` and `unknown` ahead of `dos`, because "can execute code" outranks "can
411
+ be made slow". On the real tree this was not cosmetic: the ONE critical (a happy-dom
412
+ VM-context-escape → RCE) printed sixth, wedged between ReDoS entries, purely because
413
+ `bun audit` emits in package order.
414
+ */
415
+ const NATURE_RANK = {
416
+ compromise: 0,
417
+ unknown: 1,
418
+ dos: 2,
419
+ };
420
+ function bySeverityThenNature(a, b) {
421
+ const sev = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
422
+ if (sev !== 0)
423
+ return sev;
424
+ const nature = NATURE_RANK[a.risk?.nature ?? 'unknown'] -
425
+ NATURE_RANK[b.risk?.nature ?? 'unknown'];
426
+ if (nature !== 0)
427
+ return nature;
428
+ return a.package.localeCompare(b.package);
429
+ }
430
+ function line(group) {
431
+ const adv = group.advisory;
432
+ // The risk label is triage sugar, not a verdict — this finding blocks either way.
433
+ const risk = adv.risk ? ` [${adv.risk.label}]` : '';
434
+ const basis = adv.risk ? `\n ${adv.risk.basis}` : '';
435
+ const ranges = group.ranges.length
436
+ ? `\n affects ${group.ranges.join(', ')}`
437
+ : '';
438
+ return (` ${adv.severity.toUpperCase().padEnd(8)} ${adv.package}${risk}\n` +
439
+ ` ${adv.title}${basis}${ranges}\n` +
440
+ ` ${adv.ghsa ?? adv.id} — ${adv.url}`);
441
+ }
442
+ /*
443
+ Below-threshold findings used to be collected and never shown, which made them
444
+ invisible: a moderate today is a high the day someone re-scores it, and you want to
445
+ have seen it coming. They get ONE line each — severity, package, id, truncated title
446
+ — so the whole tail is scannable without competing with what actually blocks.
447
+ */
448
+ function compactLine(group) {
449
+ const adv = group.advisory;
450
+ const title = adv.title.length > 68 ? adv.title.slice(0, 67) + '…' : adv.title;
451
+ return (` ${adv.severity.padEnd(8)} ${adv.package.padEnd(22)} ` +
452
+ `${(adv.ghsa ?? String(adv.id)).padEnd(22)} ${title}`);
453
+ }
454
+ /**
455
+ * Print the verdict. Blocking findings first (why the build is failing), then
456
+ * active gates, expired/invalid gates, stale gates, and — when blocking — the
457
+ * due-diligence footer. Returns nothing; the caller decides what to do with `ok`.
458
+ */
459
+ export function reportAudit(result, label = 'Build') {
460
+ if (!result.ran) {
461
+ console.warn(`⚠️ ${label}: could not run \`bun audit\` (offline, registry down, or ` +
462
+ `unsupported bun). Skipping the dependency gate for this run.`);
463
+ return;
464
+ }
465
+ if (result.gated.length) {
466
+ console.warn(`\n🔓 ${label}: ${result.gated.length} audit finding(s) allowed by an ` +
467
+ `active gate:\n` +
468
+ result.gated
469
+ .map((g) => ` ${g.advisory.severity.toUpperCase()} ${g.advisory.package} ` +
470
+ `(${g.advisory.ghsa ?? g.advisory.id}) — ${g.daysLeft}d left\n` +
471
+ ` reason: ${g.reason}`)
472
+ .join('\n'));
473
+ }
474
+ if (result.stale.length) {
475
+ console.warn(`\n🧹 ${label}: ${result.stale.length} audit gate(s) match no current ` +
476
+ `advisory — remove them:\n` +
477
+ result.stale.map((g) => ` ${g.advisory} — ${g.reason}`).join('\n'));
478
+ }
479
+ // Everything below the blocking threshold, one line each, severity-sorted. These
480
+ // used to be collected and never printed — invisible until the day one is
481
+ // re-scored upward. Shown whether or not the build is failing.
482
+ if (result.belowThreshold.length) {
483
+ const groups = groupAdvisories(result.belowThreshold).sort((a, b) => bySeverityThenNature(a.advisory, b.advisory));
484
+ console.warn(`\nℹ️ ${label}: ${groups.length} advisory(ies) below the ${result.level} ` +
485
+ `threshold — not blocking:\n` +
486
+ groups.map(compactLine).join('\n'));
487
+ }
488
+ /*
489
+ Advisory COUNT PER PACKAGE, across every severity — the code-smell signal.
490
+
491
+ A single moderate advisory is noise; a dependency that keeps generating them is
492
+ telling you something the per-finding view cannot. Deciding to drop a library
493
+ with a long tail of quasi-flaky advisories is a legitimate engineering call, and
494
+ it needs the aggregate, which is exactly what a threshold-filtered report hides.
495
+ Only printed when something actually repeats, so a healthy tree stays silent.
496
+ */
497
+ const perPackage = new Map();
498
+ for (const adv of [...result.blocking, ...result.belowThreshold]) {
499
+ const key = `${adv.package}::${adv.ghsa ?? adv.id}`;
500
+ if (!perPackage.has(key))
501
+ perPackage.set(key, 0);
502
+ }
503
+ const tally = new Map();
504
+ for (const key of perPackage.keys()) {
505
+ const pkg = key.split('::')[0];
506
+ tally.set(pkg, (tally.get(pkg) ?? 0) + 1);
507
+ }
508
+ const repeat = Array.from(tally.entries())
509
+ .filter(([, n]) => n > 1)
510
+ .sort((a, b) => b[1] - a[1]);
511
+ if (repeat.length) {
512
+ console.warn(`\n📊 ${label}: advisories per package — a package that keeps producing them ` +
513
+ `may be worth replacing, not just patching:\n` +
514
+ repeat.map(([pkg, n]) => ` ${String(n).padStart(3)} ${pkg}`).join('\n'));
515
+ }
516
+ if (result.ok) {
517
+ if (result.blocking.length === 0 && result.ran) {
518
+ // Quiet success line so the build log shows the gate ran.
519
+ console.log(`✅ ${label}: dependency audit clean (level: ${result.level}).`);
520
+ }
521
+ return;
522
+ }
523
+ const invalidNote = result.invalid.length
524
+ ? `\n\n ${result.invalid.length} gate(s) ignored as invalid:\n` +
525
+ result.invalid.map((i) => ` • ${i.gate.advisory}: ${i.problem}`).join('\n')
526
+ : '';
527
+ const expiredNote = result.expired.length
528
+ ? `\n\n ${result.expired.length} gate(s) have EXPIRED (re-evaluate):\n` +
529
+ result.expired
530
+ .map((g) => ` • ${g.advisory.package} (${g.advisory.ghsa ?? g.advisory.id}) — ` +
531
+ `expired ${g.daysAgo}d ago; was: ${g.reason}`)
532
+ .join('\n')
533
+ : '';
534
+ const verb = result.mode === 'warn' ? 'proceeding anyway' : 'failing the build';
535
+ const emoji = result.mode === 'warn' ? '⚠️' : '🛑';
536
+ // Group + sort so the count is the real workload and the worst thing is first.
537
+ const groups = groupAdvisories(result.blocking).sort((a, b) => bySeverityThenNature(a.advisory, b.advisory));
538
+ const packages = new Set(result.blocking.map((a) => a.package)).size;
539
+ // Only mention the raw finding count when grouping actually collapsed something,
540
+ // so the usual case stays quiet.
541
+ const spread = groups.length === result.blocking.length
542
+ ? `in ${packages} package${packages === 1 ? '' : 's'}`
543
+ : `in ${packages} package${packages === 1 ? '' : 's'}, ` +
544
+ `from ${result.blocking.length} findings`;
545
+ console.error(`\n${emoji} ${label}: ${groups.length} unaddressed ` +
546
+ `${result.level}+ dependency advisory(ies) ${spread} — ${verb}.\n\n` +
547
+ groups.map(line).join('\n\n') +
548
+ expiredNote +
549
+ invalidNote +
550
+ `\n\n${DUE_DILIGENCE}\n\n` +
551
+ ` To accept a risk on a deadline, add to your site config's ` +
552
+ `\`audit.allow\`:\n` +
553
+ ` { advisory: '<GHSA-…|id|package>', reason: '…', expires: 'YYYY-MM-DD' }\n` +
554
+ ` Turn the gate off with \`audit: false\` or TOSIJS_AUDIT=off (not recommended).\n`);
555
+ }
@@ -13,6 +13,8 @@ import { watch } from 'chokidar';
13
13
  import { $, spawn } from 'bun';
14
14
  import { buildSite } from './orchestrator';
15
15
  import { preflight } from './preflight';
16
+ import { auditDependencies, reportAudit } from './audit-guard';
17
+ import { openDevBrowser } from './open-browser';
16
18
  const TEST_RESULTS_FILE = '.browser-tests.json';
17
19
  const DEFAULT_IDLE_HOURS = 8;
18
20
  /**
@@ -374,7 +376,7 @@ export async function devServer(config, opts = {}) {
374
376
  console.error('');
375
377
  }
376
378
  else if (results.passed > 0) {
377
- console.log(`\n✓ Browser tests: ${results.passed} passed\n`);
379
+ console.log(`\n✅ Browser tests: ${results.passed} passed\n`);
378
380
  }
379
381
  if (testReportResolve && (results.passed > 0 || results.failed > 0)) {
380
382
  testReportResolve(results);
@@ -470,7 +472,21 @@ export async function devServer(config, opts = {}) {
470
472
  `should be ~0 — if this keeps climbing, restart and report it.`);
471
473
  }
472
474
  };
473
- if (!testMode) {
475
+ /*
476
+ Serve WITHOUT watching (`DEV_NO_WATCH=1`).
477
+
478
+ An automated suite wants a server, not a rebuilder. Playwright's `webServer` ran
479
+ `bun start`, so a watcher sat live for the whole run and `rm -rf docs/` +
480
+ regenerate could land underneath a test that was mid-navigation — the lane
481
+ flaked against its own build system, which reads as a product bug and is the
482
+ worst kind of red. `testMode` already implied this, but that flag also means
483
+ "drive haltija", so it could not be reused. Pair this with dropping `--watch`
484
+ from the command to silence bun's own watcher too.
485
+ */
486
+ const noWatch = process.env.DEV_NO_WATCH === '1' || process.env.DEV_NO_WATCH === 'true';
487
+ if (noWatch)
488
+ console.log('Watching disabled (DEV_NO_WATCH) — serving only.');
489
+ if (!testMode && !noWatch) {
474
490
  // Rebuild on any source change. By default that's just buildSite(), but a
475
491
  // consumer whose full build has steps BEYOND buildSite — e.g. a custom IIFE
476
492
  // bundle built separately (because it needs a Bun plugin buildSite can't
@@ -479,7 +495,10 @@ export async function devServer(config, opts = {}) {
479
495
  // (iife.js, etc.) is deleted on the first rebuild and, without opts.build,
480
496
  // never regenerated — leaving the page's /iife.js to 404 into the SPA
481
497
  // fallback (it "loads as html"). Serialize builds and coalesce bursts.
482
- const runBuild = opts.build ?? (() => buildSite(config));
498
+ // Watch rebuilds skip the dependency audit: it hits the registry over the
499
+ // network, and re-running it on every hot-reload save would add latency to the
500
+ // edit loop and break offline dev. The audit ran once at launch (below).
501
+ const runBuild = opts.build ?? (() => buildSite(config, { skipAudit: true }));
483
502
  // Rebuild-storm detector.
484
503
  //
485
504
  // The other way this process eats the machine is not a leak but a LOOP: if the
@@ -577,6 +596,30 @@ export async function devServer(config, opts = {}) {
577
596
  void rebuild();
578
597
  });
579
598
  }
599
+ // ── dependency audit (synchronous, before we bind the port) ─────────────────
600
+ //
601
+ // Runs BEFORE the server comes up, not after. The audit is sub-second (local
602
+ // resolution + one registry round-trip, bounded by AUDIT_TIMEOUT_MS and failing
603
+ // open on timeout), so the wait costs nothing next to the alternative: an async
604
+ // audit spikes a server that is already listening and possibly already in use,
605
+ // which reads as a crash and races whatever the developer started doing in the
606
+ // meantime. A gate you wait for cannot be raced.
607
+ //
608
+ // THROWS rather than exits: `devServer` is a public export of `tosijs-ui/site`,
609
+ // and refusing to start is the caller's business to handle — same contract as the
610
+ // launch-time preflight above. (The health tick below still exits, because by then
611
+ // we own a running server and stopping it IS the guard.)
612
+ // Interactive only — `--test` gates synchronously in buildSite().
613
+ if (!testMode) {
614
+ const audit = await auditDependencies(config.audit);
615
+ if (audit.mode !== 'off')
616
+ reportAudit(audit, 'Dev server');
617
+ if (!audit.ok && audit.mode === 'fail') {
618
+ throw new Error('dev server: refusing to start — unaddressed dependency advisory (see above). ' +
619
+ "Fix it, gate it with a reason + expiry in `audit.allow`, set `audit: { mode: 'warn' }`, " +
620
+ 'or TOSIJS_AUDIT=off.');
621
+ }
622
+ }
580
623
  await ensureDevCerts();
581
624
  const server = Bun.serve({
582
625
  port: PORT,
@@ -624,6 +667,19 @@ export async function devServer(config, opts = {}) {
624
667
  },
625
668
  });
626
669
  console.log(`Listening on https://localhost:${PORT}`);
670
+ // ── open (or bring to front) this project's browser tab ─────────────────────
671
+ //
672
+ // create-react-app's "open the tab" trick: reuse the project's existing tab
673
+ // instead of spawning a new one on every launch/restart. Interactive only, off
674
+ // by default (config.openBrowser), and self-skips in CI / non-TTY. Best-effort,
675
+ // fire-and-forget — never blocks startup, never throws. See open-browser.ts.
676
+ if (!testMode) {
677
+ void openDevBrowser({
678
+ url: `https://localhost:${PORT}/`,
679
+ setting: config.openBrowser,
680
+ name: config.name,
681
+ });
682
+ }
627
683
  // ── health tick ───────────────────────────────────────────────────────────
628
684
  //
629
685
  // Everything else in this file is edge-triggered: the RSS check fires after a
@@ -18,6 +18,8 @@ export interface GenerateSiteConfig {
18
18
  /** default og:image (per-page overridable via doc metadata) */
19
19
  ogImage?: string;
20
20
  projectLinks?: ProjectLinks;
21
+ /** brand mark left of the title: icon name, image URL, or inline <svg> */
22
+ logo?: string;
21
23
  /** header-bar links (rendered as real <a> for no-JS, upgraded on hydration) */
22
24
  navbarLinks?: LinkItem[];
23
25
  /** translation table (TSV) for the settings menu's language picker */
@@ -251,6 +251,7 @@ export async function generateSite(config) {
251
251
  const configAttr = escapeAttr(JSON.stringify({
252
252
  projectName: config.projectName,
253
253
  projectLinks: config.projectLinks,
254
+ logo: config.logo,
254
255
  }));
255
256
  // The theme stylesheet (config.stylesUrl) is written separately by
256
257
  // ./generate-css.ts; pages here just <link> to it.