diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md
index 1c63b68..869bc72 100644
--- a/docs/roadmap/remediation.md
+++ b/docs/roadmap/remediation.md
@@ -219,6 +219,8 @@ selection rules and native comparison status.
## R6 — P3: Render Stage IDs as Data in Markdown
+**Status:** Completed. Markdown escapes stage labels as literal cell text. Tests cover Markdown syntax, a pipe next to a backslash, and exact YAML IDs. A rendered table check confirmed the label stays in one cell.
+
**Category**
Incomplete output escaping in Stage 13.
diff --git a/internal/core/reporting/markdown.go b/internal/core/reporting/markdown.go
index 7cb715a..c716631 100644
--- a/internal/core/reporting/markdown.go
+++ b/internal/core/reporting/markdown.go
@@ -48,11 +48,11 @@ func Markdown(snapshot Snapshot, secrets ...string) []byte {
fmt.Fprintln(&b, "## Work-unit errors")
fmt.Fprintln(&b)
- writeMatrix(&b, snapshot, safe, false)
+ writeMatrix(&b, snapshot, r, false)
fmt.Fprintln(&b)
fmt.Fprintln(&b, "## Recoveries")
fmt.Fprintln(&b)
- writeMatrix(&b, snapshot, safe, true)
+ writeMatrix(&b, snapshot, r, true)
fmt.Fprintln(&b)
fmt.Fprintln(&b, "Legend: `—` = not started; `n*` = interrupted with n recorded errors or recoveries. Completed error cells are 0 or 1.")
fmt.Fprintln(&b)
@@ -71,7 +71,7 @@ func showCount(value *int) string {
return fmt.Sprint(*value)
}
-func writeMatrix(b *strings.Builder, snapshot Snapshot, safe func(string) string, recovery bool) {
+func writeMatrix(b *strings.Builder, snapshot Snapshot, redactor redaction.Redactor, recovery bool) {
if len(snapshot.StageNames) == 0 || len(snapshot.WorkUnits) == 0 {
fmt.Fprintln(b, "No work units.")
return
@@ -79,7 +79,7 @@ func writeMatrix(b *strings.Builder, snapshot Snapshot, safe func(string) string
columns := snapshot.StageNames
fmt.Fprint(b, "| Chunk |")
for _, name := range columns {
- fmt.Fprintf(b, " %s |", safe(name))
+ fmt.Fprintf(b, " %s |", stageCellEscaper.Replace(html.EscapeString(redactor.String(name))))
}
fmt.Fprint(b, "\n| --- |")
for range columns {
@@ -120,6 +120,14 @@ func writeMatrix(b *strings.Builder, snapshot Snapshot, safe func(string) string
}
}
+// Encode input backslashes so they cannot interfere with Markdown escapes.
+var stageCellEscaper = strings.NewReplacer(
+ "\r\n", "
", "\r", "
", "\n", "
",
+ "\\", "\", "|", "\\|", "`", "\\`",
+ "[", "\\[", "]", "\\]", "*", "\\*", "_", "\\_", "~", "\\~",
+ "://", "://", "www.", "www.", "@", "@",
+)
+
func writeReasons(b *strings.Builder, title string, counts map[string]int, safe func(string) string) {
fmt.Fprintf(b, "### %s\n\n", title)
if len(counts) == 0 {
diff --git a/internal/core/reporting/markdown_test.go b/internal/core/reporting/markdown_test.go
index 64b14ac..a0fcb2e 100644
--- a/internal/core/reporting/markdown_test.go
+++ b/internal/core/reporting/markdown_test.go
@@ -33,11 +33,11 @@ func TestMarkdownFixedLayoutAndCounts(t *testing.T) {
// Compare only complete table rows, preserving order and states while leaving
// prose and spacing outside the fixed matrices free to change.
wantRows := []string{
- "| Chunk | grammar_1 | grammar_2 |",
+ "| Chunk | grammar\\_1 | grammar\\_2 |",
"| --- | ---: | ---: |",
"| Chunk 1 (segments 1–2) | 1 | 0* |",
"| Chunk 2 (segments 3–4) | 0 | — |",
- "| Chunk | grammar_1 | grammar_2 |",
+ "| Chunk | grammar\\_1 | grammar\\_2 |",
"| --- | ---: | ---: |",
"| Chunk 1 (segments 1–2) | 1 | 2* |",
"| Chunk 2 (segments 3–4) | 0 | — |",
@@ -93,11 +93,35 @@ func TestMarkdownNoWorkAndEscapedStageLabel(t *testing.T) {
t.Fatal(text)
}
text = string(Markdown(Snapshot{RunID: "r", StageNames: []string{"bad|label`\nsecret"}, WorkUnits: []WorkUnit{{StageIndex: 0, ChunkIndex: 0, State: "completed"}}}, "secret"))
- if !strings.Contains(text, "bad\\|label\\`
[REDACTED]") {
+ if !strings.Contains(text, "bad\\|label\\`
\\[REDACTED\\]") {
t.Fatal(text)
}
}
+func TestStageIDIsLiteralInMarkdownAndExactInYAML(t *testing.T) {
+ id := `[party](https://example.invalid/) *bold* _emphasis_ ~~strike~~ \| `
+ snapshot := Snapshot{
+ StageNames: []string{StageLabel(0, "grammar", &id), StageLabel(1, "grammar", nil)},
+ WorkUnits: []WorkUnit{{StageIndex: 0, ChunkIndex: 0, State: "completed", ErrorCount: 1}},
+ Errors: []ErrorRecord{{Scope: "work_unit", StageID: &id}},
+ }
+ header := `| Chunk | 1: grammar (\[party\](https://example.invalid/) \*bold\* \_emphasis\_ \~\~strike\~\~ \\| <tag>) | 2: grammar |`
+ if rows := markdownTableRows(string(Markdown(snapshot))); len(rows) == 0 || rows[0] != header {
+ t.Fatalf("stage labels did not stay in their cells: %v", rows)
+ }
+ data, err := YAML(snapshot.ErrorsDocument())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded struct{ Errors []ErrorRecord }
+ if err := yaml.Unmarshal(data, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if len(decoded.Errors) != 1 || decoded.Errors[0].StageID == nil || *decoded.Errors[0].StageID != id {
+ t.Fatalf("stage ID changed in YAML: %s", data)
+ }
+}
+
func markdownTableRows(text string) []string {
var rows []string
for _, line := range strings.Split(text, "\n") {