diff --git a/src/support/dashboardHtml.ts b/src/support/dashboardHtml.ts index 7f1a5a49..b475cf8d 100644 --- a/src/support/dashboardHtml.ts +++ b/src/support/dashboardHtml.ts @@ -318,7 +318,9 @@ const DASHBOARD_HTML = ` case "process:spawn": fetchProcesses(); fetchCoordination(); - addLogLine({ taskId: ev.data.taskId || "system", stage: ev.data.stage || "spawn", line: "Process spawned PID=" + ev.data.pid + " stage=" + ev.data.stage + (ev.data.model ? " model=" + ev.data.model : "") }); + const spawnLine = "Process spawned PID=" + ev.data.pid + " stage=" + ev.data.stage + + (ev.data.model ? " model=" + ev.data.model : ""); + addLogLine({ taskId: ev.data.taskId || "system", stage: ev.data.stage || "spawn", line: spawnLine }); break; case "process:exit": fetchProcesses(); @@ -420,7 +422,8 @@ const DASHBOARD_HTML = ` const priorityColor = issue.priority === 1 ? 'var(--red)' : issue.priority === 2 ? 'var(--amber)' : 'var(--dim)'; html += '
'; const title = String(issue.title || ''); - html += '
' + escapeHtml(issue.identifier) + ': ' + escapeHtml(title.substring(0, 40)) + (title.length > 40 ? '...' : '') + '
'; + const issueTitle = escapeHtml(issue.identifier) + ': ' + escapeHtml(title.substring(0, 40)) + (title.length > 40 ? '...' : ''); + html += '
' + issueTitle + '
'; html += '
' + escapeHtml(issue.reason) + '
'; if (issue.project?.name) { html += '
๐Ÿ“ ' + escapeHtml(issue.project.name) + '
'; @@ -437,7 +440,8 @@ const DASHBOARD_HTML = ` const priorityColor = issue.priority === 1 ? 'var(--red)' : issue.priority === 2 ? 'var(--amber)' : 'var(--dim)'; html += '
'; const title = String(issue.title || ''); - html += '
' + escapeHtml(issue.identifier) + ': ' + escapeHtml(title.substring(0, 40)) + (title.length > 40 ? '...' : '') + '
'; + const issueTitle = escapeHtml(issue.identifier) + ': ' + escapeHtml(title.substring(0, 40)) + (title.length > 40 ? '...' : ''); + html += '
' + issueTitle + '
'; html += '
' + escapeHtml(issue.reason) + '
'; if (issue.project?.name) { html += '
๐Ÿ“ ' + escapeHtml(issue.project.name) + '
'; @@ -483,7 +487,8 @@ const DASHBOARD_HTML = ` html += '
Repos: ' + (data.repos?.length || 0) + '
'; if (data.currentPR) { - html += '
Processing: ' + escapeHtml(data.currentPR) + '
'; + const prValue = '' + escapeHtml(data.currentPR) + ''; + html += '
Processing: ' + prValue + '
'; } html += '
Last run: ' + formatTime(data.lastRun) + '
'; @@ -802,7 +807,7 @@ const DASHBOARD_HTML = ` "modules:" + s.totalModules + " tests:" + s.totalTestFiles + " untested:" + s.untestedModules.length + " churn:" + (s.avgChurnScore || 0).toFixed(2) + - (s.hotModules.length ? " hot:" + s.hotModules.slice(0,3).map(function(m){return m.split("/").pop()}).join(",") : "") + + (s.hotModules.length ? " hot:" + s.hotModules.slice(0,3).map(function(m){return escapeHtml(m.split("/").pop())}).join(",") : "") + "
" ); } @@ -943,10 +948,12 @@ const DASHBOARD_HTML = ` var parts = name.split("-"); return parts[parts.length - 1]; } + const TOKEN_MILLION = 1000000; + const TOKEN_THOUSAND = 1000; function fmtTokens(n) { if (n == null) return ""; - if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; - if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + if (n >= TOKEN_MILLION) return (n / 1000000).toFixed(1) + "M"; + if (n >= TOKEN_THOUSAND) return (n / 1000).toFixed(1) + "k"; return String(n); } function buildStageDetails(r) { @@ -969,7 +976,7 @@ const DASHBOARD_HTML = ` if (r.summary) addLine("Summary", escapeHtml(r.summary)); if (r.decision) { - const cls = "sd-decision-" + r.decision; + const cls = "sd-decision-" + safeCssClass(r.decision); addLine("Decision", "" + escapeHtml(r.decision.toUpperCase()) + ""); } if (r.feedback) addLine("Feedback", escapeHtml(r.feedback)); @@ -1046,7 +1053,7 @@ const DASHBOARD_HTML = ` // without having to expand. let inlineSummary = ""; if (r.decision) { - const cls = "sd-decision-" + r.decision; + const cls = "sd-decision-" + safeCssClass(r.decision); inlineSummary = "" + escapeHtml(r.decision.toUpperCase()) + "" + (r.feedback ? " ยท " + escapeHtml(r.feedback.slice(0, 80)) : ""); } else if (r.summary) { @@ -1304,6 +1311,12 @@ const DASHBOARD_HTML = ` function escapeAttr(text) { return String(text || "").replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } + function safeCssClass(text) { + const cleaned = String(text || "").replace(/[^a-zA-Z0-9_-]/g, "_"); + // Never emit an empty class token: "sd-decision-" + "" would produce a + // dangling selector prefix that can match unintended elements. + return cleaned.length > 0 ? cleaned : "_"; + } function escapeJsArgAttr(text) { return escapeAttr(JSON.stringify(String(text || ""))); } @@ -1624,7 +1637,7 @@ const DASHBOARD_HTML = ` '' + lead + '' + '' + escapeHtml(p.stage) + '' + '' + escapeHtml(modelStr) + '' + - '' + escapeHtml(projName) + '' + + '' + escapeHtml(projName) + '' + '' + act + '' + '' + dur + '' + btn + diff --git a/src/support/gitTracker.ts b/src/support/gitTracker.ts index 3bf8b9c5..2734c95f 100644 --- a/src/support/gitTracker.ts +++ b/src/support/gitTracker.ts @@ -313,6 +313,10 @@ function runGitCommand(cwd: string, args: string[], env?: NodeJS.ProcessEnv): Pr let stdout = ''; let stderr = ''; let settled = false; + // Streaming UTF-8 decoders: state is kept across chunks so a multibyte + // character split at a chunk boundary is not corrupted (unlike data.toString()). + const stdoutDecoder = new TextDecoder('utf-8'); + const stderrDecoder = new TextDecoder('utf-8'); const timer = setTimeout(() => { if (settled) return; settled = true; // claim settlement before the late close/error handlers run @@ -320,12 +324,15 @@ function runGitCommand(cwd: string, args: string[], env?: NodeJS.ProcessEnv): Pr reject(new Error(`git ${args.join(' ')} timed out after ${GIT_CMD_TIMEOUT_MS}ms`)); }, GIT_CMD_TIMEOUT_MS); - proc.stdout.on('data', (data) => { stdout += data.toString(); }); - proc.stderr.on('data', (data) => { stderr += data.toString(); }); + proc.stdout.on('data', (data) => { stdout += stdoutDecoder.decode(data, { stream: true }); }); + proc.stderr.on('data', (data) => { stderr += stderrDecoder.decode(data, { stream: true }); }); proc.on('close', (code) => { settled = true; clearTimeout(timer); + // Flush any remaining bytes held by the decoders (truncated final multibyte char) + stdout += stdoutDecoder.decode(); + stderr += stderrDecoder.decode(); if (code === 0) { resolve(stdout); } else {