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") {