Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 32 additions & 19 deletions actions/setup/js/trace_graders.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -460,10 +460,40 @@ function evaluateThreshold(value, direction, threshold) {
function sanitizeSummaryText(value) {
return String(value ?? "")
.replace(/\r?\n/g, " ")
.replace(/\\/g, "\\\\")
.replace(/\|/g, "\\|")
Comment thread
Copilot marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Markdown escaping here is incomplete: if a cell already contains a backslash before a pipe (for example A\\|B), this replacement turns it into A\\\\|B, which GitHub Markdown reduces back to an escaped backslash plus a real column separator. That lets untrusted grader data still split the table and corrupt the summary.

💡 Why this matters

This helper is meant to harden untrusted grader names/sources/units before putting them into a Markdown table. Right now it only handles the simple | case, so crafted input can still break the table layout.

Escape backslashes before pipes (or avoid Markdown tables entirely for untrusted content), and add a regression test for an input like "A\\|B":

return String(value ?? "")
  .replace(/\\/g, "\\\\")
  .replace(/\|/g, "\\|")
  .replace((r/redacted)?\n/g, " ")
  ...

.replace(/[<>&]/g, ch => (ch === "<" ? "&lt;" : ch === ">" ? "&gt;" : "&amp;"))
.trim();
}

/**
* @param {GraderResult} result
* @returns {result is GraderResult & {value: number}}
*/
function hasComputedValue(result) {
return typeof result.value === "number";
}

/**
* Build the Graders section body for the GitHub Actions step summary.
* @param {GraderResult[]} results
* @returns {string}
*/
function buildGradersSummaryBody(results) {
const computedResults = results.filter(hasComputedValue);
if (computedResults.length === 0) {
return "\n\nNo grader values available.\n\n";
}

const statusLabels = { pass: "Pass", fail: "Fail", error: "Error", unavailable: "Unavailable" };
const rows = computedResults.map(result => {
const value = String(Number(result.value.toFixed(4)));
return `| ${statusLabels[result.status] || "Unknown"} | ${sanitizeSummaryText(result.name)} | ${sanitizeSummaryText(result.source)} | ${value} | ${sanitizeSummaryText(result.unit || "—")} |`;
});

return ["", "", "| Status | Grader | Source | Value | Unit |", "| --- | --- | --- | --- | --- |", ...rows, "", ""].join("\n");
}

/**
* Normalize a grader result from either built-in number or custom object return.
* @param {string} id
Expand Down Expand Up @@ -806,25 +836,7 @@ async function main(manifestB64, execSpecB64) {
}

// Step summary
core.summary.addHeading("Graders", 3);
const tableResults = results.filter(r => r.status !== "unavailable");
if (tableResults.length > 0) {
const rows = tableResults.map(r => {
const statusIcon = r.status === "pass" ? "✅" : r.status === "fail" ? "❌" : "⚠️";
const val = r.value !== null ? String(Number(r.value.toFixed(4))) : "—";
return [statusIcon, sanitizeSummaryText(r.name), r.source, val, sanitizeSummaryText(r.unit || "—")];
});
core.summary.addTable([
[
{ data: "", header: true },
{ data: "Grader", header: true },
{ data: "Source", header: true },
{ data: "Value", header: true },
{ data: "Unit", header: true },
],
...rows,
]);
}
core.summary.addDetails("Graders", buildGradersSummaryBody(results));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This summary table is still broken in the step summary: @actions/core's addDetails() does not insert the blank lines GitHub-flavored Markdown needs before a table, so the rows render as literal text instead of a table and the closing </details> can end up glued to the last row.

💡 Why this matters

The previous addTable() implementation produced a valid table automatically. This replacement regresses the UI for every run, which defeats the purpose of surfacing grader results in a readable summary.

A safe fix is to wrap the generated body with blank lines, or build the details block manually:

core.summary
  .addRaw("<details><summary>Graders</summary>\n\n")
  .addRaw(buildGradersSummaryBody(results))
  .addRaw("\n\n</details>\n");

Alternatively, make buildGradersSummaryBody() return leading/trailing blank lines and add a regression test around the final summary markup.

const errResults = results.filter(r => r.error);
if (errResults.length > 0) {
const errLines = errResults.map(r => `- **${sanitizeSummaryText(r.id)}**: runtime error (see step logs)`).join("\n");
Expand All @@ -850,6 +862,7 @@ module.exports = {
runCustomGrader,
runOperationalValueGrader,
normalizeResult,
buildGradersSummaryBody,
evaluateThreshold,
BUILTIN_GRADERS,
BUILTIN_META,
Expand Down
37 changes: 37 additions & 0 deletions actions/setup/js/trace_graders.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
runBuiltinGrader,
runCustomGrader,
normalizeResult,
buildGradersSummaryBody,
evaluateThreshold,
BUILTIN_GRADERS,
BUILTIN_META,
Expand Down Expand Up @@ -68,6 +69,42 @@ function makeTrace(overrides = {}) {
}

describe("trace_graders", () => {
describe("buildGradersSummaryBody", () => {
it("renders all computed grader values without emojis", () => {
const summary = buildGradersSummaryBody([
{ id: "tool-success-rate", name: "Tool success rate", value: 0.98765, unit: "ratio", status: "pass", source: "builtin" },
{ id: "custom", name: "Custom", value: 2, unit: "count", status: "fail", source: "inline" },
{ id: "unavailable", name: "Unavailable", value: null, unit: "", status: "unavailable", source: "builtin" },
]);

expect(summary).toContain("| Pass | Tool success rate | builtin | 0.9877 | ratio |");
expect(summary).toContain("| Fail | Custom | inline | 2 | count |");
expect(summary).not.toContain("Unavailable");
expect(summary).not.toMatch(/[\u{1F300}-\u{1FAFF}]/u);
});

it("escapes untrusted table cells", () => {
const summary = buildGradersSummaryBody([{ id: "custom", name: "Custom | <grader>", value: 1, unit: "unit|&", status: "pass", source: "inline|source" }]);

expect(summary).toContain("Custom \\| &lt;grader&gt;");
expect(summary).toContain("inline\\|source");
expect(summary).toContain("unit\\|&amp;");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Missing test for the empty-results branch — buildGradersSummaryBody([]) should return "No grader values available.", but there's no assertion covering that path.

💡 Suggested test
it("returns a message when no grader has a computed value", () => {
  const summary = buildGradersSummaryBody([
    { id: "no-value", name: "No value", value: null, unit: "", status: "unavailable", source: "builtin" },
  ]);
  expect(summary).toBe("No grader values available.");
});

Without this test the fallback message can silently regress.

@copilot please address this.


it("escapes backslashes before pipes", () => {
const summary = buildGradersSummaryBody([{ id: "custom", name: "A\\|B", value: 1, unit: "count", status: "pass", source: "inline" }]);

expect(summary).toContain("| Pass | A\\\\\\|B | inline | 1 | count |");
});

it("surrounds the table with blank lines for details rendering", () => {
const summary = buildGradersSummaryBody([{ id: "custom", name: "Custom", value: 1, unit: "count", status: "pass", source: "inline" }]);

expect(summary.startsWith("\n\n")).toBe(true);
expect(summary.endsWith("\n\n")).toBe(true);
});
});

describe("archiveOperationalValueEvaluator", () => {
it("writes only evaluator bytes matching the frozen digest", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "operational-value-evaluator-archive-"));
Expand Down
Loading