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 {