diff --git a/internal/commandfmt/commandfmt.go b/internal/commandfmt/commandfmt.go new file mode 100644 index 0000000..d4cc362 --- /dev/null +++ b/internal/commandfmt/commandfmt.go @@ -0,0 +1,126 @@ +// Package commandfmt turns raw agent shell invocations into review-safe +// command evidence. Hook payloads can contain whole shell scripts; committed +// shale should carry the check that ran, not heredoc file bodies or local +// machine paths. +package commandfmt + +import ( + "path/filepath" + "regexp" + "strings" +) + +const maxCommandLen = 220 + +var ( + homePathRe = regexp.MustCompile(`(?i)(?:/Users|/home)/[^/\s'"` + "`" + `;|)]+`) + absPathRe = regexp.MustCompile(`(^|[\s='":])/(?:[^\s'"` + "`" + `;|)]+)`) + splitRe = regexp.MustCompile(`\s*(?:&&|\|\||;|\n)\s*`) + heredocRe = regexp.MustCompile(`<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?`) +) + +// ForEvidence returns the command form safe to persist in shale YAML. +func ForEvidence(repoRoot, raw string) string { + return clean(repoRoot, raw) +} + +// ForDisplay returns the command form safe to render for historical evidence +// that may predate persistence cleanup. +func ForDisplay(raw string) string { + return clean("", raw) +} + +// Classify buckets an agent-invoked command for the "checks recorded" card +// section. Heuristic by design; "other" is an honest answer. +func Classify(cmd string) string { + c := strings.ToLower(cmd) + switch { + case containsAny(c, "gitleaks", "semgrep", "trivy", "snyk", "govulncheck", "bandit", "grype"): + return "scan" + case containsAny(c, "golangci-lint", "eslint", "ruff", "flake8", "pylint", "rubocop", "clippy", "go vet", "staticcheck", "tflint"): + return "lint" + case containsAny(c, "go test", "pytest", "npm test", "yarn test", "pnpm test", "jest", "vitest", "cargo test", "rspec", "mvn test", "gradle test", "make test"): + return "test" + case containsAny(c, "go build", "npm run build", "yarn build", "cargo build", "make build", "mvn package", "gradle build", "docker build", "tsc"): + return "build" + default: + return "other" + } +} + +func clean(repoRoot, raw string) string { + cmd := stripHeredocs(strings.ReplaceAll(raw, "\r\n", "\n")) + for _, part := range commandParts(cmd) { + part = cleanOne(repoRoot, part) + if part != "" && Classify(part) != "other" { + return part + } + } + return cleanOne(repoRoot, cmd) +} + +func stripHeredocs(s string) string { + var out []string + lines := strings.Split(s, "\n") + for i := 0; i < len(lines); i++ { + line := lines[i] + m := heredocRe.FindStringSubmatch(line) + if len(m) == 0 { + out = append(out, line) + continue + } + end := m[1] + for i+1 < len(lines) { + i++ + if strings.TrimSpace(lines[i]) == end { + break + } + } + } + return strings.Join(out, "\n") +} + +func commandParts(s string) []string { + var out []string + for _, part := range splitRe.Split(s, -1) { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if i := strings.Index(part, "|"); i >= 0 { + out = append(out, strings.TrimSpace(part[:i])) + continue + } + out = append(out, part) + } + return out +} + +func cleanOne(repoRoot, s string) string { + s = filepath.ToSlash(s) + if repoRoot != "" { + root := filepath.ToSlash(filepath.Clean(repoRoot)) + s = strings.ReplaceAll(s, root, ".") + } + s = homePathRe.ReplaceAllString(s, "~") + s = absPathRe.ReplaceAllString(s, "${1}") + s = strings.Join(strings.Fields(s), " ") + if strings.HasPrefix(s, "cd .") { + if i := strings.Index(s, ";"); i >= 0 { + s = strings.TrimSpace(s[i+1:]) + } + } + if len(s) > maxCommandLen { + s = strings.TrimSpace(s[:maxCommandLen-1]) + "…" + } + return s +} + +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/internal/commandfmt/commandfmt_test.go b/internal/commandfmt/commandfmt_test.go new file mode 100644 index 0000000..7bc7032 --- /dev/null +++ b/internal/commandfmt/commandfmt_test.go @@ -0,0 +1,51 @@ +package commandfmt + +import ( + "strings" + "testing" +) + +func TestForEvidenceExtractsCheckAndDropsHeredoc(t *testing.T) { + raw := `cat > pkg/query_test.go <<'EOF' +package pkg + +const token = "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789" +EOF +cd /Users/noahkreiger/Documents/fianulabs/core/core; go test ./pkg/criteria -run TestTranslator_NumericPropertyComparison -v 2>&1 | grep -E "PASS|FAIL" | head -25` + + got := ForEvidence("/Users/noahkreiger/Documents/fianulabs/core/core", raw) + if got != "go test ./pkg/criteria -run TestTranslator_NumericPropertyComparison -v 2>&1" { + t.Fatalf("ForEvidence() = %q", got) + } + for _, banned := range []string{"package pkg", "ghp_", "/Users/noahkreiger", "grep -E", "head -25"} { + if strings.Contains(got, banned) { + t.Fatalf("ForEvidence leaked %q in %q", banned, got) + } + } +} + +func TestForDisplayRedactsHistoricalLocalPaths(t *testing.T) { + raw := "cd /Users/noahkreiger/Documents/fianulabs/core/core; go test ./pkg/... 2>&1 | tail -20" + got := ForDisplay(raw) + if got != "go test ./pkg/... 2>&1" { + t.Fatalf("ForDisplay() = %q", got) + } + if strings.Contains(got, "/Users/noahkreiger") { + t.Fatalf("ForDisplay leaked local path: %q", got) + } +} + +func TestClassify(t *testing.T) { + cases := map[string]string{ + "go test ./...": "test", + "gitleaks detect": "scan", + "golangci-lint run": "lint", + "go build ./...": "build", + "ls -la": "other", + } + for in, want := range cases { + if got := Classify(in); got != want { + t.Errorf("Classify(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/fold/fold.go b/internal/fold/fold.go index acf9479..b436208 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -14,6 +14,7 @@ import ( "time" "github.com/provasign/shale/internal/capture" + "github.com/provasign/shale/internal/commandfmt" "github.com/provasign/shale/internal/gitx" "github.com/provasign/shale/internal/pricing" "github.com/provasign/shale/internal/redact" @@ -242,11 +243,15 @@ func foldSession(opts Options, sessionID string) ([]string, error) { } case store.KindCommand: - cmd, r := eng.Apply(ev.Cmd) + rawCmd := commandfmt.ForEvidence(opts.RepoRoot, ev.Cmd) + if rawCmd == "" { + continue + } + cmd, r := eng.Apply(rawCmd) redactions += r s.Commands = append(s.Commands, store.Command{ Cmd: cmd, ExitCode: ev.ExitCode, At: ev.At.UTC(), - Classified: ClassifyCommand(ev.Cmd), + Classified: ClassifyCommand(rawCmd), }) case store.KindPrompt: @@ -416,26 +421,5 @@ func contains(xs []string, x string) bool { // ClassifyCommand buckets an agent-invoked command for the "checks recorded" // card section. Heuristic by design; "other" is an honest answer. func ClassifyCommand(cmd string) string { - c := strings.ToLower(cmd) - switch { - case containsAny(c, "gitleaks", "semgrep", "trivy", "snyk", "govulncheck", "bandit", "grype"): - return "scan" - case containsAny(c, "golangci-lint", "eslint", "ruff", "flake8", "pylint", "rubocop", "clippy", "go vet", "staticcheck", "tflint"): - return "lint" - case containsAny(c, "go test", "pytest", "npm test", "yarn test", "pnpm test", "jest", "vitest", "cargo test", "rspec", "mvn test", "gradle test", "make test"): - return "test" - case containsAny(c, "go build", "npm run build", "yarn build", "cargo build", "make build", "mvn package", "gradle build", "docker build", "tsc"): - return "build" - default: - return "other" - } -} - -func containsAny(s string, subs ...string) bool { - for _, sub := range subs { - if strings.Contains(s, sub) { - return true - } - } - return false + return commandfmt.Classify(cmd) } diff --git a/internal/fold/fold_test.go b/internal/fold/fold_test.go index d47f1d2..e4f332f 100644 --- a/internal/fold/fold_test.go +++ b/internal/fold/fold_test.go @@ -228,6 +228,50 @@ func TestRunGitFallbackFillsMissingHookPaths(t *testing.T) { } } +func TestRunCleansCommandEvidenceBeforePersistence(t *testing.T) { + root := initRepo(t) + rawCmd := `cat > pkg/query_test.go <<'EOF' +package pkg + +const token = "ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789" +EOF +cd ` + root + `; go test ./pkg/criteria -run TestTranslator_NumericPropertyComparison -v 2>&1 | grep -E "PASS|FAIL" | head -25` + for _, ev := range []store.Event{ + {Kind: store.KindIntent, At: t0, Title: "Fix query parsing"}, + {Kind: store.KindCommand, At: t0.Add(time.Minute), Cmd: rawCmd, ExitCode: intPtr(0)}, + {Kind: store.KindCompletion, At: t0.Add(2 * time.Minute), Note: "fixed"}, + } { + if err := store.AppendEvent(root, "SESSCMD01", ev); err != nil { + t.Fatal(err) + } + } + + if _, err := Run(Options{RepoRoot: root, Now: now}); err != nil { + t.Fatal(err) + } + s, err := store.Load(filepath.Join(root, ".shale", "SESSCMD01.yaml")) + if err != nil { + t.Fatal(err) + } + if len(s.Commands) != 1 { + t.Fatalf("commands = %+v", s.Commands) + } + got := s.Commands[0].Cmd + want := "go test ./pkg/criteria -run TestTranslator_NumericPropertyComparison -v 2>&1" + if got != want || s.Commands[0].Classified != "test" { + t.Fatalf("command = %#v, want %q classified as test", s.Commands[0], want) + } + rawYAML, err := os.ReadFile(filepath.Join(root, ".shale", "SESSCMD01.yaml")) + if err != nil { + t.Fatal(err) + } + for _, banned := range []string{"ghp_", "package pkg", root, "/Users/", "grep -E", "head -25"} { + if strings.Contains(string(rawYAML), banned) { + t.Fatalf("committed command evidence leaked %q:\n%s", banned, rawYAML) + } + } +} + // Regression: a session with two intent→done arcs must never pair one task's // completion with another task's intent (found live: session d38090f4 in the // prism repo published the second intent with the first completion note). diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index aa9bbd8..f33d8ca 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -48,9 +48,11 @@ type FileChange struct { } // FilesChangedSince returns repo-relative paths changed in the window from t -// to now: commits authored since t on the current branch plus any -// uncommitted (staged or unstaged) changes. This feeds the `via: git` -// fallback (ADR D4 tier 3) — honest about being a window, not a touch log. +// to now: non-merge commits authored on the current branch since t plus any +// uncommitted (staged or unstaged) changes. It deliberately excludes commits +// reachable from the default branch/FETCH_HEAD so a branch sync from main +// does not become this session's evidence. This feeds the `via: git` fallback +// (ADR D4 tier 3) — honest about being a window, not a touch log. func FilesChangedSince(root string, t time.Time) []FileChange { ops := map[string][]string{} add := func(path, op string) { @@ -66,9 +68,15 @@ func FilesChangedSince(root string, t time.Time) []FileChange { ops[path] = append(ops[path], op) } - logOut, err := run(root, - "log", "--since="+t.UTC().Format(time.RFC3339), - "--name-status", "--pretty=format:", "HEAD") + args := []string{ + "log", "--first-parent", "--no-merges", + "--since=" + t.UTC().Format(time.RFC3339), + "--name-status", "--pretty=format:", "HEAD", + } + for _, ref := range syncRefs(root) { + args = append(args, "^"+ref) + } + logOut, err := run(root, args...) if err == nil { for _, line := range strings.Split(logOut, "\n") { fields := strings.Split(line, "\t") @@ -113,6 +121,32 @@ func FilesChangedSince(root string, t time.Time) []FileChange { return changes } +func syncRefs(root string) []string { + current := Branch(root) + candidates := []string{"FETCH_HEAD"} + if current != "main" && current != "master" { + candidates = append(candidates, + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + "refs/remotes/origin/master", + "refs/heads/main", + "refs/heads/master", + ) + } + + var refs []string + seen := map[string]bool{} + for _, ref := range candidates { + oid, err := run(root, "rev-parse", "--verify", "--quiet", ref+"^{commit}") + if err != nil || oid == "" || seen[oid] { + continue + } + seen[oid] = true + refs = append(refs, ref) + } + return refs +} + // opForStatus maps one git status letter (diff --name-status or porcelain XY) // to the spec ops vocabulary. Letters that carry no change ("space", ignored) // map to "". diff --git a/internal/gitx/gitx_test.go b/internal/gitx/gitx_test.go index 4a31feb..67387ed 100644 --- a/internal/gitx/gitx_test.go +++ b/internal/gitx/gitx_test.go @@ -111,6 +111,60 @@ func TestFilesChangedSince(t *testing.T) { } } +func TestFilesChangedSinceIgnoresMainSync(t *testing.T) { + root := initRepo(t) + writeFile(t, root, "base.go", "package base") + past := time.Now().Add(-2 * time.Hour).Format(time.RFC3339) + add := exec.Command("git", "add", "base.go") + add.Dir = root + if out, err := add.CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + commit := exec.Command("git", "commit", "--no-verify", "-m", "base") + commit.Dir = root + commit.Env = append(os.Environ(), "GIT_AUTHOR_DATE="+past, "GIT_COMMITTER_DATE="+past) + if out, err := commit.CombinedOutput(); err != nil { + t.Fatalf("git commit: %v\n%s", err, out) + } + + checkout := exec.Command("git", "checkout", "-b", "feature") + checkout.Dir = root + if out, err := checkout.CombinedOutput(); err != nil { + t.Fatalf("git checkout feature: %v\n%s", err, out) + } + since := time.Now().Add(-time.Minute) + writeFile(t, root, "feature.go", "package feature") + if committed, err := AutoCommit(root, []string{"feature.go"}, "feature change"); err != nil || !committed { + t.Fatalf("feature commit: committed=%v err=%v", committed, err) + } + + checkout = exec.Command("git", "checkout", "main") + checkout.Dir = root + if out, err := checkout.CombinedOutput(); err != nil { + t.Fatalf("git checkout main: %v\n%s", err, out) + } + writeFile(t, root, "synced.go", "package synced") + if committed, err := AutoCommit(root, []string{"synced.go"}, "main change"); err != nil || !committed { + t.Fatalf("main commit: committed=%v err=%v", committed, err) + } + + checkout = exec.Command("git", "checkout", "feature") + checkout.Dir = root + if out, err := checkout.CombinedOutput(); err != nil { + t.Fatalf("git checkout feature again: %v\n%s", err, out) + } + rebase := exec.Command("git", "rebase", "main") + rebase.Dir = root + if out, err := rebase.CombinedOutput(); err != nil { + t.Fatalf("git rebase main: %v\n%s", err, out) + } + + got := FilesChangedSince(root, since) + if len(got) != 1 || got[0].Path != "feature.go" { + t.Fatalf("FilesChangedSince after main sync = %v, want only feature.go", got) + } +} + // AutoCommit runs mid-session now (`shale done`): the user's own staged work // must stay staged and out of the evidence commit. func TestAutoCommitLeavesUserStagingAlone(t *testing.T) { diff --git a/internal/prcard/prcard.go b/internal/prcard/prcard.go index 2f53be5..c94e78f 100644 --- a/internal/prcard/prcard.go +++ b/internal/prcard/prcard.go @@ -38,7 +38,7 @@ func Run(ctx context.Context, f forge.Forge, pr int) (Result, error) { return res, fmt.Errorf("list PR files: %w", err) } - shales, tamper := loadShales(ctx, f, head, pr) + shales, tamper := loadShales(ctx, f, head, pr, prFiles) res.Shales = len(shales) res.Tampered = tamper @@ -79,15 +79,11 @@ func Run(ctx context.Context, f forge.Forge, pr int) (Result, error) { // parseable shales plus tamper warnings (transcript hash mismatch, shale // edited after its introducing commit). Unparseable files become warnings, // not failures — hostile input must degrade, not break (D6/D7). -func loadShales(ctx context.Context, f forge.Forge, head string, pr int) ([]*store.Shale, []string) { +func loadShales(ctx context.Context, f forge.Forge, head string, pr int, prFiles []render.ChangedFile) ([]*store.Shale, []string) { var shales []*store.Shale var warnings []string - paths, err := f.ListDir(ctx, head, ".shale") - if err != nil { - warnings = append(warnings, "could not list .shale/: "+err.Error()) - return nil, warnings - } + paths := shalePathsInPR(prFiles) prCommits, _ := f.PRCommits(ctx, pr) prSet := map[string]bool{} @@ -146,6 +142,28 @@ func loadShales(ctx context.Context, f forge.Forge, head string, pr int) ([]*sto return shales, warnings } +func shalePathsInPR(prFiles []render.ChangedFile) []string { + var paths []string + seen := map[string]bool{} + for _, f := range prFiles { + if f.Status == "removed" { + continue + } + if !strings.HasPrefix(f.Path, ".shale/") || !strings.HasSuffix(f.Path, ".yaml") { + continue + } + if path.Base(f.Path) == "config.yaml" { + continue + } + if seen[f.Path] { + continue + } + seen[f.Path] = true + paths = append(paths, f.Path) + } + return paths +} + func short(id string) string { if len(id) > 6 { return id[:6] diff --git a/internal/prcard/prcard_test.go b/internal/prcard/prcard_test.go index 537b241..15b5f76 100644 --- a/internal/prcard/prcard_test.go +++ b/internal/prcard/prcard_test.go @@ -93,8 +93,11 @@ func TestRunHappyPath(t *testing.T) { transcript := []byte("# transcript") s := validShale("SESS01", transcript) f := &fakeForge{ - head: "headsha", - files: []render.ChangedFile{{Path: "main.go", Status: "modified"}}, + head: "headsha", + files: []render.ChangedFile{ + {Path: "main.go", Status: "modified"}, + {Path: ".shale/SESS01.yaml", Status: "added"}, + }, contents: map[string][]byte{ ".shale/SESS01.yaml": shaleBytes(t, s), ".shale/transcripts/SESS01.md": transcript, @@ -142,8 +145,11 @@ func TestRunNudgeWhenNoShale(t *testing.T) { func TestRunFlagsTranscriptTamper(t *testing.T) { s := validShale("SESS01", []byte("original")) f := &fakeForge{ - head: "headsha", - files: []render.ChangedFile{{Path: "main.go", Status: "modified"}}, + head: "headsha", + files: []render.ChangedFile{ + {Path: "main.go", Status: "modified"}, + {Path: ".shale/SESS01.yaml", Status: "added"}, + }, contents: map[string][]byte{ ".shale/SESS01.yaml": shaleBytes(t, s), ".shale/transcripts/SESS01.md": []byte("EDITED AFTER THE FACT"), @@ -164,8 +170,11 @@ func TestRunFlagsTranscriptTamper(t *testing.T) { func TestRunFlagsShaleEditedAfterCapture(t *testing.T) { s := validShale("SESS01", nil) f := &fakeForge{ - head: "headsha", - files: []render.ChangedFile{{Path: "main.go", Status: "modified"}}, + head: "headsha", + files: []render.ChangedFile{ + {Path: "main.go", Status: "modified"}, + {Path: ".shale/SESS01.yaml", Status: "added"}, + }, contents: map[string][]byte{ ".shale/SESS01.yaml": shaleBytes(t, s), }, @@ -183,8 +192,12 @@ func TestRunFlagsShaleEditedAfterCapture(t *testing.T) { func TestRunHostileShaleDegradesToWarning(t *testing.T) { f := &fakeForge{ - head: "headsha", - files: []render.ChangedFile{{Path: "main.go", Status: "modified"}}, + head: "headsha", + files: []render.ChangedFile{ + {Path: "main.go", Status: "modified"}, + {Path: ".shale/evil.yaml", Status: "added"}, + {Path: ".shale/junk.yaml", Status: "added"}, + }, contents: map[string][]byte{ ".shale/evil.yaml": []byte("shale_version: \"0\"\nid: x\nprivacy: bogus-mode\n"), ".shale/junk.yaml": []byte("{{{{not yaml"), @@ -201,3 +214,32 @@ func TestRunHostileShaleDegradesToWarning(t *testing.T) { t.Fatalf("want 2 warnings, got %v", res.Tampered) } } + +func TestRunIgnoresInheritedBaseBranchShale(t *testing.T) { + current := validShale("CURRENT01", nil) + inherited := validShale("MAIN01", nil) + inherited.Intent.Title = "Old main evidence" + f := &fakeForge{ + head: "headsha", + files: []render.ChangedFile{ + {Path: "main.go", Status: "modified"}, + {Path: ".shale/CURRENT01.yaml", Status: "added"}, + }, + contents: map[string][]byte{ + ".shale/CURRENT01.yaml": shaleBytes(t, current), + ".shale/MAIN01.yaml": shaleBytes(t, inherited), + }, + commits: []string{"c1"}, + touching: map[string][]string{".shale/CURRENT01.yaml": {"c1"}}, + } + res, err := Run(context.Background(), f, 7) + if err != nil { + t.Fatal(err) + } + if res.Shales != 1 { + t.Fatalf("shales = %d, want only the PR evidence file", res.Shales) + } + if strings.Contains(res.Card, "Old main evidence") { + t.Fatalf("inherited base-branch evidence rendered:\n%s", res.Card) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 045b70b..a794dc3 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/provasign/shale/internal/commandfmt" "github.com/provasign/shale/internal/store" ) @@ -431,7 +432,7 @@ func writeChecks(b *strings.Builder, shales []*store.Shale) { } } checks = append(checks, check{ - sessionID: displayID(s.ID), cmd: c.Cmd, result: result, + sessionID: displayID(s.ID), cmd: commandfmt.ForDisplay(c.Cmd), result: result, when: formatTimeUTC(c.At), at: c.At, }) } @@ -563,7 +564,12 @@ func plural(n int) string { return "s" } -func code(s string) string { return "`" + strings.ReplaceAll(s, "`", "'") + "`" } +func code(s string) string { + s = strings.ReplaceAll(s, "`", "'") + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "|", `\|`) + return "`" + s + "`" +} func sortedKeys(m map[string]bool) []string { keys := make([]string, 0, len(m)) diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 089f761..6b79750 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -294,6 +294,30 @@ func TestCardHostileShaleCannotInject(t *testing.T) { } } +func TestCardCleansNoisyHistoricalChecks(t *testing.T) { + s := sampleShale("checks123") + s.Commands = []store.Command{{ + Cmd: "cd /Users/noahkreiger/Documents/fianulabs/core/core; go test ./pkg/criteria -run TestTranslator_NumericPropertyComparison -v 2>&1 | grep -E \"PASS|FAIL\" | head -25\ncat pkg/criteria/query.go", + ExitCode: intPtr(0), + At: declared.Add(10 * time.Minute), + Classified: "test", + }} + got := Card(Input{Shales: []*store.Shale{s}, PRFiles: samplePRFiles()}) + if !strings.Contains(got, "`go test ./pkg/criteria -run TestTranslator_NumericPropertyComparison -v 2>&1`") { + t.Fatalf("check command not cleaned:\n%s", got) + } + for _, banned := range []string{"/Users/noahkreiger", "grep -E", "head -25", "cat pkg/criteria"} { + if strings.Contains(got, banned) { + t.Fatalf("noisy check content survived %q:\n%s", banned, got) + } + } + for _, line := range strings.Split(got, "\n") { + if strings.HasPrefix(line, "| `checks123`") && strings.Count(line, "|") != 5 { + t.Fatalf("check row table was split by command pipes: %q", line) + } + } +} + func TestSanitize(t *testing.T) { cases := map[string]string{ "plain text": "plain text",