From c2c1f8153027fd946fc345c6a1b6dda1cfa1218c Mon Sep 17 00:00:00 2001
From: mertcano <35747700+mertcano@users.noreply.github.com>
Date: Fri, 9 Oct 2026 00:01:15 +0300
Subject: [PATCH 1/3] Fix script tag breakout in generated HTML maps
### Summary of Changes
Resolves **F19 (High)** and **F20 (High)** from the Uphold Workspace Security Audit:
- **HTML Breakout / Stored XSS Prevention (F19 & F20):**
- Updated `build_perf_map.mjs` and `build_lcp_map.mjs` to escape `` closing tags before injecting JSON payloads into template script elements.
- Neutralizes DOM-based script execution vectors originating from untrusted target repository filenames and traced Chrome resource URLs.
- Aligns HTML map generation with the defensive escaping already present in `build_run_report.mjs`.
### Verification
- `npm test` passes cleanly (6/6 self-tests, 6/6 invariant validations).
- End-to-end artifact generation confirms inputs containing `` are serialized as `<\/script>` within HTML `` would otherwise
+ // close the script element and execute attacker-chosen script when the report is opened. This
+ // report is published to GitHub Pages, so escape before injecting.
+ const html = template
+ .split(LIB_MARKER).join(lib)
+ .split(DATA_MARKER).join(dataJson.replace(/<\/(script)/gi, '<\\/$1'));
+
+ fs.writeFileSync(outFile, html);
+ const outAbs = path.resolve(outFile);
+ const kb = (fs.statSync(outAbs).size / 1024).toFixed(0);
+
+ console.log(`\nbrowsergnome lcp-map`);
+ console.log(` wrote ${outAbs} (${kb} KB, standalone)`);
+ console.log(` ${payload.meta.url || '(no url)'} · LCP ${payload.meta.lcpMs != null ? Math.round(payload.meta.lcpMs) + 'ms' : '?'}`);
+ console.log(` ${payload.meta.counts.network} network · ${payload.meta.counts.chunk} chunk · ${payload.meta.counts.module} module` +
+ (payload.meta.degraded ? ' (degraded: no bundle stats, chunk-only)' : ''));
+ if (payload.meta.transport?.legacyHttp) {
+ const t = payload.meta.transport;
+ console.log(` ⚠ transport: HTTP/1.1 (${t.http1Count}/${t.firstPartyTotal} first-party requests) — results may not represent production (HTTP/2 CDN). See banner.`);
+ }
+ if (payload.meta.throttled) {
+ const t = payload.meta.throttled;
+ console.log(` throttled reference (${t.label}): FCP ${t.fcpMs != null ? Math.round(t.fcpMs) + 'ms' : '?'} ·` +
+ ` LCP ${t.lcpMs != null ? Math.round(t.lcpMs) + 'ms' : '?'} · TBT ${t.tbtMs != null ? Math.round(t.tbtMs) + 'ms' : '?'}`);
+ }
+ if (payload.meta.crux) {
+ const m = payload.meta.crux.metrics;
+ const src = payload.meta.crux.source === 'psi' ? 'via PageSpeed Insights, keyless' : 'CrUX API';
+ console.log(` field data (${src}, real users, origin): LCP ${m.lcp.p75 != null ? Math.round(m.lcp.p75) + 'ms' : '?'} ·` +
+ ` CLS ${m.cls.p75 != null ? m.cls.p75.toFixed(2) : '?'} · INP ${m.inp.p75 != null ? Math.round(m.inp.p75) + 'ms' : '?'}`);
+ } else if (prodUrl) {
+ console.log(` no CrUX field data for ${prodUrl} (low-traffic origin, or offline) — field-data tier omitted`);
+ }
+ console.log(` open with: open ${outAbs}\n`);
+
+ if (open) {
+ const opener = process.platform === 'darwin' ? 'open'
+ : process.platform === 'win32' ? 'cmd' : 'xdg-open';
+ const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs];
+ execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); });
+ }
+}
+
+// Guarded like lcp_attribution.mjs/trace_metrics.mjs's identical checks — lets
+// resolveProdUrl/getFieldData be imported by another script (e.g. a test)
+// without also triggering a CLI run as a side effect.
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => { console.error(err); process.exit(1); });
+}
diff --git a/skills/browsergnome/scripts/build_perf_map.mjs b/skills/browsergnome/scripts/build_perf_map.mjs
old mode 100644
new mode 100755
index 86204a8..2d21d42
--- a/skills/browsergnome/scripts/build_perf_map.mjs
+++ b/skills/browsergnome/scripts/build_perf_map.mjs
@@ -1,98 +1,103 @@
-#!/usr/bin/env node
-/**
- * build_perf_map.mjs — merge graph.json into the HTML template to produce a
- * single, fully standalone perf-map.html.
- *
- * "Standalone" is a hard requirement: the vendored 3d-force-graph build (which
- * bundles three.js) and the graph payload are both INLINED into the output, so
- * perf-map.html renders from file:// with the network disabled and no sibling
- * files. That's what makes it safe to hand to anyone / open on a plane.
- *
- * Usage:
- * node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]
- *
- * --renders overlays a Page Tools render profile (`[{file, renders, selfMs}]`, written by the agent
- * from a react_* profiling pass — see references/page-tools.md) onto matching nodes before injection.
- */
-
-import fs from 'node:fs';
-import path from 'node:path';
-import process from 'node:process';
-import { fileURLToPath } from 'node:url';
-import { execFile } from 'node:child_process';
-import { mergeRenders } from './page_tools.mjs';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const ASSETS = path.resolve(__dirname, '..', 'assets');
-const TEMPLATE = path.join(ASSETS, 'perf-map.template.html');
-const LIB = path.join(ASSETS, '3d-force-graph.min.js');
-
-const LIB_MARKER = '/*__FORCE_GRAPH_LIB__*/';
-const DATA_MARKER = '/*__GRAPH_DATA__*/';
-
-function main() {
- const args = process.argv.slice(2);
- const open = args.includes('--open');
- const outIdx = args.indexOf('--out');
- const outFile = outIdx >= 0 ? args[outIdx + 1] : 'perf-map.html';
- const rendersIdx = args.indexOf('--renders');
- const rendersFile = rendersIdx >= 0 ? args[rendersIdx + 1] : null;
- const valueIdxs = [outIdx, rendersIdx].filter((i) => i >= 0).map((i) => i + 1); // flag values, not the graph arg
- const graphFile = args.find((a, i) => !a.startsWith('--') && !valueIdxs.includes(i));
-
- if (!graphFile) {
- console.error('usage: node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]');
- process.exit(1);
- }
- for (const [p, what] of [[graphFile, 'graph'], [TEMPLATE, 'template'], [LIB, 'vendored lib'], ...(rendersFile ? [[rendersFile, 'render profile']] : [])]) {
- if (!fs.existsSync(p)) { console.error(`missing ${what}: ${p}`); process.exit(1); }
- }
-
- let graphRaw = fs.readFileSync(graphFile, 'utf8');
- let graph = JSON.parse(graphRaw); // validate it parses before embedding
- let unmatched = [];
- if (rendersFile) {
- ({ graph, unmatched } = mergeRenders(graph, JSON.parse(fs.readFileSync(rendersFile, 'utf8'))));
- graphRaw = JSON.stringify(graph);
- }
- const template = fs.readFileSync(TEMPLATE, 'utf8');
- const lib = fs.readFileSync(LIB, 'utf8');
-
- if (!template.includes(LIB_MARKER) || !template.includes(DATA_MARKER)) {
- console.error('template is missing an injection marker — did the template change?');
- process.exit(1);
- }
-
- // Literal split/join (not regex) so the 1.3MB minified lib can't be
- // misinterpreted as a replacement pattern.
- const html = template
- .split(LIB_MARKER).join(lib)
- .split(DATA_MARKER).join(graphRaw.trim());
-
- fs.writeFileSync(outFile, html);
- const outAbs = path.resolve(outFile);
- const kb = (fs.statSync(outAbs).size / 1024).toFixed(0);
-
- const s = graph.meta?.stats || {};
- console.log(`\nbrowsergnome perf-map`);
- console.log(` wrote ${outAbs} (${kb} KB, standalone)`);
- console.log(` ${s.nodes ?? '?'} modules · ${s.hotspots ?? '?'} hotspot(s) · ${s.findings ?? '?'} findings`);
- if (rendersFile) {
- const overlaid = graph.nodes.filter((n) => 'renders' in n).length;
- console.log(` render overlay: ${overlaid} module(s)${unmatched.length ? ` · ${unmatched.length} profiled file(s) not in the graph: ${unmatched.join(', ')}` : ''}`);
- }
- if (graph.top3?.length) {
- console.log(` Top-3:`);
- for (const t of graph.top3) console.log(` ${t.rank}. ${t.paste} (debt ${t.debt})`);
- }
- console.log(` open with: open ${outAbs}\n`);
-
- if (open) {
- const opener = process.platform === 'darwin' ? 'open'
- : process.platform === 'win32' ? 'cmd' : 'xdg-open';
- const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs];
- execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); });
- }
-}
-
-main();
+#!/usr/bin/env node
+/**
+ * build_perf_map.mjs — merge graph.json into the HTML template to produce a
+ * single, fully standalone perf-map.html.
+ *
+ * "Standalone" is a hard requirement: the vendored 3d-force-graph build (which
+ * bundles three.js) and the graph payload are both INLINED into the output, so
+ * perf-map.html renders from file:// with the network disabled and no sibling
+ * files. That's what makes it safe to hand to anyone / open on a plane.
+ *
+ * Usage:
+ * node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]
+ *
+ * --renders overlays a Page Tools render profile (`[{file, renders, selfMs}]`, written by the agent
+ * from a react_* profiling pass — see references/page-tools.md) onto matching nodes before injection.
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+import { execFile } from 'node:child_process';
+import { mergeRenders } from './page_tools.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ASSETS = path.resolve(__dirname, '..', 'assets');
+const TEMPLATE = path.join(ASSETS, 'perf-map.template.html');
+const LIB = path.join(ASSETS, '3d-force-graph.min.js');
+
+const LIB_MARKER = '/*__FORCE_GRAPH_LIB__*/';
+const DATA_MARKER = '/*__GRAPH_DATA__*/';
+
+function main() {
+ const args = process.argv.slice(2);
+ const open = args.includes('--open');
+ const outIdx = args.indexOf('--out');
+ const outFile = outIdx >= 0 ? args[outIdx + 1] : 'perf-map.html';
+ const rendersIdx = args.indexOf('--renders');
+ const rendersFile = rendersIdx >= 0 ? args[rendersIdx + 1] : null;
+ const valueIdxs = [outIdx, rendersIdx].filter((i) => i >= 0).map((i) => i + 1); // flag values, not the graph arg
+ const graphFile = args.find((a, i) => !a.startsWith('--') && !valueIdxs.includes(i));
+
+ if (!graphFile) {
+ console.error('usage: node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]');
+ process.exit(1);
+ }
+ for (const [p, what] of [[graphFile, 'graph'], [TEMPLATE, 'template'], [LIB, 'vendored lib'], ...(rendersFile ? [[rendersFile, 'render profile']] : [])]) {
+ if (!fs.existsSync(p)) { console.error(`missing ${what}: ${p}`); process.exit(1); }
+ }
+
+ let graphRaw = fs.readFileSync(graphFile, 'utf8');
+ let graph = JSON.parse(graphRaw); // validate it parses before embedding
+ let unmatched = [];
+ if (rendersFile) {
+ ({ graph, unmatched } = mergeRenders(graph, JSON.parse(fs.readFileSync(rendersFile, 'utf8'))));
+ graphRaw = JSON.stringify(graph);
+ }
+ const template = fs.readFileSync(TEMPLATE, 'utf8');
+ const lib = fs.readFileSync(LIB, 'utf8');
+
+ if (!template.includes(LIB_MARKER) || !template.includes(DATA_MARKER)) {
+ console.error('template is missing an injection marker — did the template change?');
+ process.exit(1);
+ }
+
+ // Literal split/join (not regex) so the 1.3MB minified lib can't be
+ // misinterpreted as a replacement pattern.
+ //
+ // The graph payload is embedded inside a
` would close the script element and execute in
+ // the output page. build_run_report.mjs already escapes this; do the same here.
+ const html = template
+ .split(LIB_MARKER).join(lib)
+ .split(DATA_MARKER).join(graphRaw.trim().replace(/<\/(script)/gi, '<\\/$1'));
+
+ fs.writeFileSync(outFile, html);
+ const outAbs = path.resolve(outFile);
+ const kb = (fs.statSync(outAbs).size / 1024).toFixed(0);
+
+ const s = graph.meta?.stats || {};
+ console.log(`\nbrowsergnome perf-map`);
+ console.log(` wrote ${outAbs} (${kb} KB, standalone)`);
+ console.log(` ${s.nodes ?? '?'} modules · ${s.hotspots ?? '?'} hotspot(s) · ${s.findings ?? '?'} findings`);
+ if (rendersFile) {
+ const overlaid = graph.nodes.filter((n) => 'renders' in n).length;
+ console.log(` render overlay: ${overlaid} module(s)${unmatched.length ? ` · ${unmatched.length} profiled file(s) not in the graph: ${unmatched.join(', ')}` : ''}`);
+ }
+ if (graph.top3?.length) {
+ console.log(` Top-3:`);
+ for (const t of graph.top3) console.log(` ${t.rank}. ${t.paste} (debt ${t.debt})`);
+ }
+ console.log(` open with: open ${outAbs}\n`);
+
+ if (open) {
+ const opener = process.platform === 'darwin' ? 'open'
+ : process.platform === 'win32' ? 'cmd' : 'xdg-open';
+ const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs];
+ execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); });
+ }
+}
+
+main();
From 774881c737eac3858b66c3b19934ed0d00cd30f2 Mon Sep 17 00:00:00 2001
From: mertcano <35747700+mertcano@users.noreply.github.com>
Date: Tue, 3 Nov 2026 17:39:19 +0300
Subject: [PATCH 2/3] Convert perf map builder line endings to LF
---
.../browsergnome/scripts/build_perf_map.mjs | 206 +++++++++---------
1 file changed, 103 insertions(+), 103 deletions(-)
mode change 100755 => 100644 skills/browsergnome/scripts/build_perf_map.mjs
diff --git a/skills/browsergnome/scripts/build_perf_map.mjs b/skills/browsergnome/scripts/build_perf_map.mjs
old mode 100755
new mode 100644
index 2d21d42..9954217
--- a/skills/browsergnome/scripts/build_perf_map.mjs
+++ b/skills/browsergnome/scripts/build_perf_map.mjs
@@ -1,103 +1,103 @@
-#!/usr/bin/env node
-/**
- * build_perf_map.mjs — merge graph.json into the HTML template to produce a
- * single, fully standalone perf-map.html.
- *
- * "Standalone" is a hard requirement: the vendored 3d-force-graph build (which
- * bundles three.js) and the graph payload are both INLINED into the output, so
- * perf-map.html renders from file:// with the network disabled and no sibling
- * files. That's what makes it safe to hand to anyone / open on a plane.
- *
- * Usage:
- * node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]
- *
- * --renders overlays a Page Tools render profile (`[{file, renders, selfMs}]`, written by the agent
- * from a react_* profiling pass — see references/page-tools.md) onto matching nodes before injection.
- */
-
-import fs from 'node:fs';
-import path from 'node:path';
-import process from 'node:process';
-import { fileURLToPath } from 'node:url';
-import { execFile } from 'node:child_process';
-import { mergeRenders } from './page_tools.mjs';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const ASSETS = path.resolve(__dirname, '..', 'assets');
-const TEMPLATE = path.join(ASSETS, 'perf-map.template.html');
-const LIB = path.join(ASSETS, '3d-force-graph.min.js');
-
-const LIB_MARKER = '/*__FORCE_GRAPH_LIB__*/';
-const DATA_MARKER = '/*__GRAPH_DATA__*/';
-
-function main() {
- const args = process.argv.slice(2);
- const open = args.includes('--open');
- const outIdx = args.indexOf('--out');
- const outFile = outIdx >= 0 ? args[outIdx + 1] : 'perf-map.html';
- const rendersIdx = args.indexOf('--renders');
- const rendersFile = rendersIdx >= 0 ? args[rendersIdx + 1] : null;
- const valueIdxs = [outIdx, rendersIdx].filter((i) => i >= 0).map((i) => i + 1); // flag values, not the graph arg
- const graphFile = args.find((a, i) => !a.startsWith('--') && !valueIdxs.includes(i));
-
- if (!graphFile) {
- console.error('usage: node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]');
- process.exit(1);
- }
- for (const [p, what] of [[graphFile, 'graph'], [TEMPLATE, 'template'], [LIB, 'vendored lib'], ...(rendersFile ? [[rendersFile, 'render profile']] : [])]) {
- if (!fs.existsSync(p)) { console.error(`missing ${what}: ${p}`); process.exit(1); }
- }
-
- let graphRaw = fs.readFileSync(graphFile, 'utf8');
- let graph = JSON.parse(graphRaw); // validate it parses before embedding
- let unmatched = [];
- if (rendersFile) {
- ({ graph, unmatched } = mergeRenders(graph, JSON.parse(fs.readFileSync(rendersFile, 'utf8'))));
- graphRaw = JSON.stringify(graph);
- }
- const template = fs.readFileSync(TEMPLATE, 'utf8');
- const lib = fs.readFileSync(LIB, 'utf8');
-
- if (!template.includes(LIB_MARKER) || !template.includes(DATA_MARKER)) {
- console.error('template is missing an injection marker — did the template change?');
- process.exit(1);
- }
-
- // Literal split/join (not regex) so the 1.3MB minified lib can't be
- // misinterpreted as a replacement pattern.
- //
- // The graph payload is embedded inside a
` would close the script element and execute in
- // the output page. build_run_report.mjs already escapes this; do the same here.
- const html = template
- .split(LIB_MARKER).join(lib)
- .split(DATA_MARKER).join(graphRaw.trim().replace(/<\/(script)/gi, '<\\/$1'));
-
- fs.writeFileSync(outFile, html);
- const outAbs = path.resolve(outFile);
- const kb = (fs.statSync(outAbs).size / 1024).toFixed(0);
-
- const s = graph.meta?.stats || {};
- console.log(`\nbrowsergnome perf-map`);
- console.log(` wrote ${outAbs} (${kb} KB, standalone)`);
- console.log(` ${s.nodes ?? '?'} modules · ${s.hotspots ?? '?'} hotspot(s) · ${s.findings ?? '?'} findings`);
- if (rendersFile) {
- const overlaid = graph.nodes.filter((n) => 'renders' in n).length;
- console.log(` render overlay: ${overlaid} module(s)${unmatched.length ? ` · ${unmatched.length} profiled file(s) not in the graph: ${unmatched.join(', ')}` : ''}`);
- }
- if (graph.top3?.length) {
- console.log(` Top-3:`);
- for (const t of graph.top3) console.log(` ${t.rank}. ${t.paste} (debt ${t.debt})`);
- }
- console.log(` open with: open ${outAbs}\n`);
-
- if (open) {
- const opener = process.platform === 'darwin' ? 'open'
- : process.platform === 'win32' ? 'cmd' : 'xdg-open';
- const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs];
- execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); });
- }
-}
-
-main();
+#!/usr/bin/env node
+/**
+ * build_perf_map.mjs — merge graph.json into the HTML template to produce a
+ * single, fully standalone perf-map.html.
+ *
+ * "Standalone" is a hard requirement: the vendored 3d-force-graph build (which
+ * bundles three.js) and the graph payload are both INLINED into the output, so
+ * perf-map.html renders from file:// with the network disabled and no sibling
+ * files. That's what makes it safe to hand to anyone / open on a plane.
+ *
+ * Usage:
+ * node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]
+ *
+ * --renders overlays a Page Tools render profile (`[{file, renders, selfMs}]`, written by the agent
+ * from a react_* profiling pass — see references/page-tools.md) onto matching nodes before injection.
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+import { execFile } from 'node:child_process';
+import { mergeRenders } from './page_tools.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ASSETS = path.resolve(__dirname, '..', 'assets');
+const TEMPLATE = path.join(ASSETS, 'perf-map.template.html');
+const LIB = path.join(ASSETS, '3d-force-graph.min.js');
+
+const LIB_MARKER = '/*__FORCE_GRAPH_LIB__*/';
+const DATA_MARKER = '/*__GRAPH_DATA__*/';
+
+function main() {
+ const args = process.argv.slice(2);
+ const open = args.includes('--open');
+ const outIdx = args.indexOf('--out');
+ const outFile = outIdx >= 0 ? args[outIdx + 1] : 'perf-map.html';
+ const rendersIdx = args.indexOf('--renders');
+ const rendersFile = rendersIdx >= 0 ? args[rendersIdx + 1] : null;
+ const valueIdxs = [outIdx, rendersIdx].filter((i) => i >= 0).map((i) => i + 1); // flag values, not the graph arg
+ const graphFile = args.find((a, i) => !a.startsWith('--') && !valueIdxs.includes(i));
+
+ if (!graphFile) {
+ console.error('usage: node build_perf_map.mjs [--out perf-map.html] [--renders render-profile.json] [--open]');
+ process.exit(1);
+ }
+ for (const [p, what] of [[graphFile, 'graph'], [TEMPLATE, 'template'], [LIB, 'vendored lib'], ...(rendersFile ? [[rendersFile, 'render profile']] : [])]) {
+ if (!fs.existsSync(p)) { console.error(`missing ${what}: ${p}`); process.exit(1); }
+ }
+
+ let graphRaw = fs.readFileSync(graphFile, 'utf8');
+ let graph = JSON.parse(graphRaw); // validate it parses before embedding
+ let unmatched = [];
+ if (rendersFile) {
+ ({ graph, unmatched } = mergeRenders(graph, JSON.parse(fs.readFileSync(rendersFile, 'utf8'))));
+ graphRaw = JSON.stringify(graph);
+ }
+ const template = fs.readFileSync(TEMPLATE, 'utf8');
+ const lib = fs.readFileSync(LIB, 'utf8');
+
+ if (!template.includes(LIB_MARKER) || !template.includes(DATA_MARKER)) {
+ console.error('template is missing an injection marker — did the template change?');
+ process.exit(1);
+ }
+
+ // Literal split/join (not regex) so the 1.3MB minified lib can't be
+ // misinterpreted as a replacement pattern.
+ //
+ // The graph payload is embedded inside a
` would close the script element and execute in
+ // the output page. build_run_report.mjs already escapes this; do the same here.
+ const html = template
+ .split(LIB_MARKER).join(lib)
+ .split(DATA_MARKER).join(graphRaw.trim().replace(/<\/(script)/gi, '<\\/$1'));
+
+ fs.writeFileSync(outFile, html);
+ const outAbs = path.resolve(outFile);
+ const kb = (fs.statSync(outAbs).size / 1024).toFixed(0);
+
+ const s = graph.meta?.stats || {};
+ console.log(`\nbrowsergnome perf-map`);
+ console.log(` wrote ${outAbs} (${kb} KB, standalone)`);
+ console.log(` ${s.nodes ?? '?'} modules · ${s.hotspots ?? '?'} hotspot(s) · ${s.findings ?? '?'} findings`);
+ if (rendersFile) {
+ const overlaid = graph.nodes.filter((n) => 'renders' in n).length;
+ console.log(` render overlay: ${overlaid} module(s)${unmatched.length ? ` · ${unmatched.length} profiled file(s) not in the graph: ${unmatched.join(', ')}` : ''}`);
+ }
+ if (graph.top3?.length) {
+ console.log(` Top-3:`);
+ for (const t of graph.top3) console.log(` ${t.rank}. ${t.paste} (debt ${t.debt})`);
+ }
+ console.log(` open with: open ${outAbs}\n`);
+
+ if (open) {
+ const opener = process.platform === 'darwin' ? 'open'
+ : process.platform === 'win32' ? 'cmd' : 'xdg-open';
+ const oArgs = process.platform === 'win32' ? ['/c', 'start', '', outAbs] : [outAbs];
+ execFile(opener, oArgs, (err) => { if (err) console.error('could not auto-open:', err.message); });
+ }
+}
+
+main();
From bc4faf717b17add02f1719846d7c89a788d9c338 Mon Sep 17 00:00:00 2001
From: Xavier Costa
Date: Tue, 3 Nov 2026 15:05:21 +0000
Subject: [PATCH 3/3] Harden script tag escaping in HTML builders
Escape every `<` as `<` in all three builders, which also covers
the `