1
0

bot_context_test.go 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. package main
  2. // The Claude bot prompts in .github/workflows/claude-bot.yml no longer restate
  3. // repository facts; they read .github/claude/repo-context.md instead. A stale
  4. // claim in that file is invisible until it produces a wrong review, so every
  5. // claim a machine can check is pinned here.
  6. import (
  7. "os"
  8. "path/filepath"
  9. "regexp"
  10. "strings"
  11. "testing"
  12. )
  13. const (
  14. botContextPath = ".github/claude/repo-context.md"
  15. botRubricPath = ".github/claude/review-rubric.md"
  16. ciWorkflowPath = ".github/workflows/ci.yml"
  17. )
  18. func readRepoFile(t *testing.T, path string) string {
  19. t.Helper()
  20. b, err := os.ReadFile(path)
  21. if err != nil {
  22. t.Fatalf("read %s: %v", path, err)
  23. }
  24. return string(b)
  25. }
  26. // section returns the text between two markers, so a table is matched only
  27. // inside the heading that owns it.
  28. func section(t *testing.T, doc, from, to string) string {
  29. t.Helper()
  30. i := strings.Index(doc, from)
  31. if i < 0 {
  32. t.Fatalf("%s no longer contains the heading %q", botContextPath, from)
  33. }
  34. rest := doc[i+len(from):]
  35. if j := strings.Index(rest, to); j >= 0 {
  36. return rest[:j]
  37. }
  38. return rest
  39. }
  40. func TestBotContextLocaleFileCount(t *testing.T) {
  41. doc := readRepoFile(t, botContextPath)
  42. m := regexp.MustCompile("`internal/web/translation/` \\((\\d+) files\\)").FindStringSubmatch(doc)
  43. if m == nil {
  44. t.Fatalf("%s no longer states the locale file count as \"`internal/web/translation/` (N files)\"", botContextPath)
  45. }
  46. files, err := filepath.Glob("internal/web/translation/*.json")
  47. if err != nil {
  48. t.Fatalf("glob locales: %v", err)
  49. }
  50. if got := len(files); m[1] != itoa(got) {
  51. t.Errorf("%s claims %s locale files, internal/web/translation/ holds %d; update the claim and every prompt that relies on it", botContextPath, m[1], got)
  52. }
  53. }
  54. func itoa(n int) string {
  55. if n == 0 {
  56. return "0"
  57. }
  58. var b []byte
  59. for n > 0 {
  60. b = append([]byte{byte('0' + n%10)}, b...)
  61. n /= 10
  62. }
  63. return string(b)
  64. }
  65. func TestBotContextNamesRealCIJobs(t *testing.T) {
  66. doc := readRepoFile(t, botContextPath)
  67. ci := readRepoFile(t, ciWorkflowPath)
  68. table := section(t, doc, "## What CI runs", "**What CI does NOT prove.**")
  69. rows := regexp.MustCompile("(?m)^\\| `([a-z0-9-]+)` \\|").FindAllStringSubmatch(table, -1)
  70. if len(rows) < 5 {
  71. t.Fatalf("expected the CI table in %s to list at least 5 jobs, found %d", botContextPath, len(rows))
  72. }
  73. for _, r := range rows {
  74. t.Run(r[1], func(t *testing.T) {
  75. if !strings.Contains(ci, "\n "+r[1]+":\n") {
  76. t.Errorf("%s describes a CI job %q that %s does not define", botContextPath, r[1], ciWorkflowPath)
  77. }
  78. })
  79. }
  80. }
  81. func TestBotContextNamesRealPaths(t *testing.T) {
  82. doc := readRepoFile(t, botContextPath) + readRepoFile(t, botRubricPath)
  83. // internal/web/dist and frontend/node_modules are build output: absent from a
  84. // fresh clone, created by `make dist-stub` and `npm ci`.
  85. generated := map[string]bool{
  86. "internal/web/dist/": true,
  87. "frontend/node_modules": true,
  88. "frontend/src/generated/": true,
  89. }
  90. seen := map[string]bool{}
  91. for _, m := range regexp.MustCompile("`([^`]+)`").FindAllStringSubmatch(doc, -1) {
  92. p := m[1]
  93. if !regexp.MustCompile(`^(internal|frontend|docs|tools|\.github)/`).MatchString(p) ||
  94. strings.ContainsAny(p, "*{ ") || generated[p] || seen[p] {
  95. continue
  96. }
  97. seen[p] = true
  98. t.Run(p, func(t *testing.T) {
  99. if _, err := os.Stat(strings.TrimSuffix(p, "/")); err != nil {
  100. t.Errorf("%s names %q, which does not exist; the bot prompts trust this file", botContextPath, p)
  101. }
  102. })
  103. }
  104. if len(seen) < 20 {
  105. t.Errorf("expected the bot context to name at least 20 repository paths, found %d - has the file been gutted?", len(seen))
  106. }
  107. }
  108. func TestBotContextSkipGatesExist(t *testing.T) {
  109. doc := readRepoFile(t, botContextPath)
  110. table := section(t, doc, "**What CI does NOT prove.**", "Mutation testing")
  111. // [A-Z0-9_] and not [A-Z_]: XRAY_E2E_BINARY carries a digit, and excluding it
  112. // silently dropped that gate from the check instead of failing.
  113. gates := regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(table, -1)
  114. if len(gates) < 5 {
  115. t.Fatalf("expected at least 5 skip-gate variables in %s, found %d", botContextPath, len(gates))
  116. }
  117. var sources []string
  118. err := filepath.WalkDir("internal", func(path string, d os.DirEntry, err error) error {
  119. if err != nil {
  120. return err
  121. }
  122. if !d.IsDir() && strings.HasSuffix(path, ".go") {
  123. sources = append(sources, path)
  124. }
  125. return nil
  126. })
  127. if err != nil {
  128. t.Fatalf("walk internal: %v", err)
  129. }
  130. for _, g := range gates {
  131. t.Run(g[1], func(t *testing.T) {
  132. for _, f := range sources {
  133. if strings.Contains(readRepoFile(t, f), g[1]) {
  134. return
  135. }
  136. }
  137. t.Errorf("%s lists %s as a test skip gate, but no .go file under internal/ reads it", botContextPath, g[1])
  138. })
  139. }
  140. }