1
0

bot_context_test.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. package main
  2. // The bot prompts under .github/workflows/ read .github/claude/repo-context.md
  3. // instead of restating repo facts; a stale claim there is invisible, so pin it.
  4. import (
  5. "os"
  6. "path/filepath"
  7. "regexp"
  8. "strings"
  9. "testing"
  10. )
  11. const (
  12. botContextPath = ".github/claude/repo-context.md"
  13. reviewPath = "REVIEW.md"
  14. ciWorkflowPath = ".github/workflows/ci.yml"
  15. )
  16. func readRepoFile(t *testing.T, path string) string {
  17. t.Helper()
  18. b, err := os.ReadFile(path)
  19. if err != nil {
  20. t.Fatalf("read %s: %v", path, err)
  21. }
  22. return string(b)
  23. }
  24. // section returns the text between two markers, so a table is matched only
  25. // inside the heading that owns it.
  26. func section(t *testing.T, doc, from, to string) string {
  27. t.Helper()
  28. i := strings.Index(doc, from)
  29. if i < 0 {
  30. t.Fatalf("%s no longer contains the heading %q", botContextPath, from)
  31. }
  32. rest := doc[i+len(from):]
  33. if before, _, ok := strings.Cut(rest, to); ok {
  34. return before
  35. }
  36. return rest
  37. }
  38. func TestBotContextLocaleFileCount(t *testing.T) {
  39. doc := readRepoFile(t, botContextPath)
  40. m := regexp.MustCompile("`internal/web/translation/` \\((\\d+) files\\)").FindStringSubmatch(doc)
  41. if m == nil {
  42. t.Fatalf("%s no longer states the locale file count as \"`internal/web/translation/` (N files)\"", botContextPath)
  43. }
  44. files, err := filepath.Glob("internal/web/translation/*.json")
  45. if err != nil {
  46. t.Fatalf("glob locales: %v", err)
  47. }
  48. if got := len(files); m[1] != itoa(got) {
  49. 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)
  50. }
  51. }
  52. func itoa(n int) string {
  53. if n == 0 {
  54. return "0"
  55. }
  56. var b []byte
  57. for n > 0 {
  58. b = append([]byte{byte('0' + n%10)}, b...)
  59. n /= 10
  60. }
  61. return string(b)
  62. }
  63. func TestBotContextNamesRealCIJobs(t *testing.T) {
  64. doc := readRepoFile(t, botContextPath)
  65. ci := readRepoFile(t, ciWorkflowPath)
  66. table := section(t, doc, "## What CI runs", "**What CI does NOT prove.**")
  67. rows := regexp.MustCompile("(?m)^\\| `([a-z0-9-]+)` \\|").FindAllStringSubmatch(table, -1)
  68. if len(rows) < 5 {
  69. t.Fatalf("expected the CI table in %s to list at least 5 jobs, found %d", botContextPath, len(rows))
  70. }
  71. for _, r := range rows {
  72. t.Run(r[1], func(t *testing.T) {
  73. if !strings.Contains(ci, "\n "+r[1]+":\n") {
  74. t.Errorf("%s describes a CI job %q that %s does not define", botContextPath, r[1], ciWorkflowPath)
  75. }
  76. })
  77. }
  78. }
  79. func TestBotContextNamesRealPaths(t *testing.T) {
  80. // REVIEW.md briefs the review job the way repo-context.md briefs the
  81. // issue bot, so both get their paths pinned.
  82. // internal/web/dist and frontend/node_modules are build output: absent from a
  83. // fresh clone, created by `make dist-stub` and `npm ci`.
  84. generated := map[string]bool{
  85. "internal/web/dist/": true,
  86. "frontend/node_modules": true,
  87. "frontend/src/generated/": true,
  88. }
  89. seen := map[string]bool{}
  90. counts := map[string]int{}
  91. for _, src := range []string{botContextPath, reviewPath} {
  92. for _, m := range regexp.MustCompile("`([^`]+)`").FindAllStringSubmatch(readRepoFile(t, src), -1) {
  93. p := m[1]
  94. if !regexp.MustCompile(`^(internal|frontend|docs|tools|\.github)/`).MatchString(p) ||
  95. strings.ContainsAny(p, "*{ ") || generated[p] || seen[p] {
  96. continue
  97. }
  98. seen[p] = true
  99. counts[src]++
  100. t.Run(p, func(t *testing.T) {
  101. if _, err := os.Stat(strings.TrimSuffix(p, "/")); err != nil {
  102. t.Errorf("%s names %q, which does not exist; the bot prompts trust this file", src, p)
  103. }
  104. })
  105. }
  106. }
  107. if counts[botContextPath] < 20 {
  108. t.Errorf("expected the bot context to name at least 20 repository paths, found %d - has the file been gutted?", counts[botContextPath])
  109. }
  110. }
  111. func TestBotContextSkipGatesExist(t *testing.T) {
  112. doc := readRepoFile(t, botContextPath)
  113. table := section(t, doc, "**What CI does NOT prove.**", "Mutation testing")
  114. // [A-Z0-9_] and not [A-Z_]: XRAY_E2E_BINARY carries a digit, and excluding it
  115. // silently dropped that gate from the check instead of failing.
  116. gates := regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(table, -1)
  117. if len(gates) < 5 {
  118. t.Fatalf("expected at least 5 skip-gate variables in %s, found %d", botContextPath, len(gates))
  119. }
  120. var sources []string
  121. err := filepath.WalkDir("internal", func(path string, d os.DirEntry, err error) error {
  122. if err != nil {
  123. return err
  124. }
  125. if !d.IsDir() && strings.HasSuffix(path, ".go") {
  126. sources = append(sources, path)
  127. }
  128. return nil
  129. })
  130. if err != nil {
  131. t.Fatalf("walk internal: %v", err)
  132. }
  133. for _, g := range gates {
  134. t.Run(g[1], func(t *testing.T) {
  135. for _, f := range sources {
  136. if strings.Contains(readRepoFile(t, f), g[1]) {
  137. return
  138. }
  139. }
  140. t.Errorf("%s lists %s as a test skip gate, but no .go file under internal/ reads it", botContextPath, g[1])
  141. })
  142. }
  143. }
  144. // REVIEW.md tells the reviewer which CI job proves what, and which skip gates
  145. // mean a green run proved nothing. Both go stale silently on a rename.
  146. func TestReviewNamesRealCIJobsAndGates(t *testing.T) {
  147. doc := readRepoFile(t, reviewPath)
  148. ci := readRepoFile(t, ciWorkflowPath)
  149. // Hyphenated only: a single-word job name is indistinguishable from prose.
  150. jobs := regexp.MustCompile("`([a-z0-9]+(?:-[a-z0-9]+)+)`").FindAllStringSubmatch(doc, -1)
  151. if len(jobs) < 2 {
  152. t.Fatalf("expected %s to name at least 2 CI jobs in backticks, found %d", reviewPath, len(jobs))
  153. }
  154. for _, j := range jobs {
  155. t.Run(j[1], func(t *testing.T) {
  156. if !strings.Contains(ci, "\n "+j[1]+":\n") {
  157. t.Errorf("%s names a CI job %q that %s does not define", reviewPath, j[1], ciWorkflowPath)
  158. }
  159. })
  160. }
  161. for _, g := range regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(doc, -1) {
  162. t.Run(g[1], func(t *testing.T) {
  163. if strings.Contains(ci, g[1]) {
  164. t.Errorf("%s claims %s is never set in CI, but %s sets it", reviewPath, g[1], ciWorkflowPath)
  165. }
  166. })
  167. }
  168. }
  169. // The i18n rule is the one REVIEW.md states as a number, so it is the one that
  170. // goes wrong silently when a locale is added.
  171. func TestReviewLocaleFileCount(t *testing.T) {
  172. doc := readRepoFile(t, reviewPath)
  173. m := regexp.MustCompile(`(\d+) locale files`).FindStringSubmatch(doc)
  174. if m == nil {
  175. t.Fatalf("%s no longer states the i18n rule as \"N locale files\"", reviewPath)
  176. }
  177. files, err := filepath.Glob("internal/web/translation/*.json")
  178. if err != nil {
  179. t.Fatalf("glob locales: %v", err)
  180. }
  181. if got := len(files); m[1] != itoa(got) {
  182. t.Errorf("%s tells the reviewer to expect %s locale files, internal/web/translation/ holds %d", reviewPath, m[1], got)
  183. }
  184. }