bot_context_test.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. package main
  2. // The bot prompts and the files they read (issue-analyst-*.md, REVIEW.md) state
  3. // repo facts no build checks; a stale or oversized one fails silently, so pin them.
  4. import (
  5. "os"
  6. "path/filepath"
  7. "regexp"
  8. "strings"
  9. "testing"
  10. "unicode/utf16"
  11. "github.com/goccy/go-yaml"
  12. )
  13. const (
  14. analystContextPath = ".github/claude/issue-analyst-context.md"
  15. analystInstructionsPath = ".github/claude/issue-analyst-instructions.md"
  16. reviewPath = "REVIEW.md"
  17. ciWorkflowPath = ".github/workflows/ci.yml"
  18. )
  19. func readRepoFile(t *testing.T, path string) string {
  20. t.Helper()
  21. b, err := os.ReadFile(path)
  22. if err != nil {
  23. t.Fatalf("read %s: %v", path, err)
  24. }
  25. return string(b)
  26. }
  27. // section returns the text between two markers, so a table is matched only
  28. // inside the heading that owns it.
  29. func section(t *testing.T, doc, from, to string) string {
  30. t.Helper()
  31. i := strings.Index(doc, from)
  32. if i < 0 {
  33. t.Fatalf("%s no longer contains the heading %q", analystContextPath, from)
  34. }
  35. rest := doc[i+len(from):]
  36. if before, _, ok := strings.Cut(rest, to); ok {
  37. return before
  38. }
  39. return rest
  40. }
  41. func TestAnalystContextLocaleFileCount(t *testing.T) {
  42. doc := readRepoFile(t, analystContextPath)
  43. m := regexp.MustCompile("`internal/web/translation/` \\((\\d+) files\\)").FindStringSubmatch(doc)
  44. if m == nil {
  45. t.Fatalf("%s no longer states the locale file count as \"`internal/web/translation/` (N files)\"", analystContextPath)
  46. }
  47. files, err := filepath.Glob("internal/web/translation/*.json")
  48. if err != nil {
  49. t.Fatalf("glob locales: %v", err)
  50. }
  51. if got := len(files); m[1] != itoa(got) {
  52. t.Errorf("%s claims %s locale files, internal/web/translation/ holds %d; update the claim and every prompt that relies on it", analystContextPath, m[1], got)
  53. }
  54. }
  55. func itoa(n int) string {
  56. if n == 0 {
  57. return "0"
  58. }
  59. var b []byte
  60. for n > 0 {
  61. b = append([]byte{byte('0' + n%10)}, b...)
  62. n /= 10
  63. }
  64. return string(b)
  65. }
  66. func TestAnalystContextNamesRealCIJobs(t *testing.T) {
  67. doc := readRepoFile(t, analystContextPath)
  68. ci := readRepoFile(t, ciWorkflowPath)
  69. table := section(t, doc, "## What CI runs", "**What CI does NOT prove.**")
  70. rows := regexp.MustCompile("(?m)^\\| `([a-z0-9-]+)` \\|").FindAllStringSubmatch(table, -1)
  71. if len(rows) < 5 {
  72. t.Fatalf("expected the CI table in %s to list at least 5 jobs, found %d", analystContextPath, len(rows))
  73. }
  74. for _, r := range rows {
  75. t.Run(r[1], func(t *testing.T) {
  76. if !strings.Contains(ci, "\n "+r[1]+":\n") {
  77. t.Errorf("%s describes a CI job %q that %s does not define", analystContextPath, r[1], ciWorkflowPath)
  78. }
  79. })
  80. }
  81. }
  82. func TestAnalystContextNamesRealPaths(t *testing.T) {
  83. // REVIEW.md briefs the review job the way the two issue-analyst files brief
  84. // the analyst, so all three get their paths pinned.
  85. // internal/web/dist and frontend/node_modules are build output: absent from a
  86. // fresh clone, created by `make dist-stub` and `npm ci`.
  87. generated := map[string]bool{
  88. "internal/web/dist/": true,
  89. "frontend/node_modules": true,
  90. "frontend/src/generated/": true,
  91. }
  92. seen := map[string]bool{}
  93. counts := map[string]int{}
  94. for _, src := range []string{analystContextPath, analystInstructionsPath, reviewPath} {
  95. for _, m := range regexp.MustCompile("`([^`]+)`").FindAllStringSubmatch(readRepoFile(t, src), -1) {
  96. p := m[1]
  97. if !regexp.MustCompile(`^(internal|frontend|docs|tools|\.github)/`).MatchString(p) ||
  98. strings.ContainsAny(p, "*{ ") || generated[p] || seen[p] {
  99. continue
  100. }
  101. seen[p] = true
  102. counts[src]++
  103. t.Run(p, func(t *testing.T) {
  104. if _, err := os.Stat(strings.TrimSuffix(p, "/")); err != nil {
  105. t.Errorf("%s names %q, which does not exist; the bot prompts trust this file", src, p)
  106. }
  107. })
  108. }
  109. }
  110. if counts[analystContextPath] < 20 {
  111. t.Errorf("expected the bot context to name at least 20 repository paths, found %d - has the file been gutted?", counts[analystContextPath])
  112. }
  113. }
  114. func TestAnalystContextSkipGatesExist(t *testing.T) {
  115. doc := readRepoFile(t, analystContextPath)
  116. table := section(t, doc, "**What CI does NOT prove.**", "Mutation testing")
  117. // [A-Z0-9_] and not [A-Z_]: XRAY_E2E_BINARY carries a digit, and excluding it
  118. // silently dropped that gate from the check instead of failing.
  119. gates := regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(table, -1)
  120. if len(gates) < 5 {
  121. t.Fatalf("expected at least 5 skip-gate variables in %s, found %d", analystContextPath, len(gates))
  122. }
  123. var sources []string
  124. err := filepath.WalkDir("internal", func(path string, d os.DirEntry, err error) error {
  125. if err != nil {
  126. return err
  127. }
  128. if !d.IsDir() && strings.HasSuffix(path, ".go") {
  129. sources = append(sources, path)
  130. }
  131. return nil
  132. })
  133. if err != nil {
  134. t.Fatalf("walk internal: %v", err)
  135. }
  136. for _, g := range gates {
  137. t.Run(g[1], func(t *testing.T) {
  138. for _, f := range sources {
  139. if strings.Contains(readRepoFile(t, f), g[1]) {
  140. return
  141. }
  142. }
  143. t.Errorf("%s lists %s as a test skip gate, but no .go file under internal/ reads it", analystContextPath, g[1])
  144. })
  145. }
  146. }
  147. // REVIEW.md tells the reviewer which CI job proves what, and which skip gates
  148. // mean a green run proved nothing. Both go stale silently on a rename.
  149. func TestReviewNamesRealCIJobsAndGates(t *testing.T) {
  150. doc := readRepoFile(t, reviewPath)
  151. ci := readRepoFile(t, ciWorkflowPath)
  152. // Hyphenated only: a single-word job name is indistinguishable from prose.
  153. jobs := regexp.MustCompile("`([a-z0-9]+(?:-[a-z0-9]+)+)`").FindAllStringSubmatch(doc, -1)
  154. if len(jobs) < 2 {
  155. t.Fatalf("expected %s to name at least 2 CI jobs in backticks, found %d", reviewPath, len(jobs))
  156. }
  157. for _, j := range jobs {
  158. t.Run(j[1], func(t *testing.T) {
  159. if !strings.Contains(ci, "\n "+j[1]+":\n") {
  160. t.Errorf("%s names a CI job %q that %s does not define", reviewPath, j[1], ciWorkflowPath)
  161. }
  162. })
  163. }
  164. for _, g := range regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(doc, -1) {
  165. t.Run(g[1], func(t *testing.T) {
  166. if strings.Contains(ci, g[1]) {
  167. t.Errorf("%s claims %s is never set in CI, but %s sets it", reviewPath, g[1], ciWorkflowPath)
  168. }
  169. })
  170. }
  171. }
  172. // The i18n rule is the one REVIEW.md states as a number, so it is the one that
  173. // goes wrong silently when a locale is added.
  174. func TestReviewLocaleFileCount(t *testing.T) {
  175. doc := readRepoFile(t, reviewPath)
  176. m := regexp.MustCompile(`(\d+) locale files`).FindStringSubmatch(doc)
  177. if m == nil {
  178. t.Fatalf("%s no longer states the i18n rule as \"N locale files\"", reviewPath)
  179. }
  180. files, err := filepath.Glob("internal/web/translation/*.json")
  181. if err != nil {
  182. t.Fatalf("glob locales: %v", err)
  183. }
  184. if got := len(files); m[1] != itoa(got) {
  185. t.Errorf("%s tells the reviewer to expect %s locale files, internal/web/translation/ holds %d", reviewPath, m[1], got)
  186. }
  187. }
  188. // GitHub rejects the whole workflow once a value holding ${{ }} passes 21,000
  189. // characters, so every trigger fails with no job to read; actionlint misses it.
  190. func TestBotPromptsFitGitHubExpressionLimit(t *testing.T) {
  191. const limit = 21000
  192. files, err := filepath.Glob(".github/workflows/claude-*.yml")
  193. if err != nil || len(files) == 0 {
  194. t.Fatalf("glob bot workflows: %v (%d files)", err, len(files))
  195. }
  196. checked := 0
  197. for _, path := range files {
  198. var wf struct {
  199. Jobs map[string]struct {
  200. Steps []struct {
  201. With map[string]any `yaml:"with"`
  202. } `yaml:"steps"`
  203. } `yaml:"jobs"`
  204. }
  205. if err := yaml.Unmarshal([]byte(readRepoFile(t, path)), &wf); err != nil {
  206. t.Fatalf("parse %s: %v", path, err)
  207. }
  208. for job, j := range wf.Jobs {
  209. for _, step := range j.Steps {
  210. for key, v := range step.With {
  211. s, ok := v.(string)
  212. if !ok || !strings.Contains(s, "${{") {
  213. continue
  214. }
  215. checked++
  216. // GitHub counts .NET string length: UTF-16 code units.
  217. if n := len(utf16.Encode([]rune(s))); n > limit {
  218. t.Errorf("%s job %s with.%s is %d characters; GitHub rejects the workflow over %d", path, job, key, n, limit)
  219. }
  220. }
  221. }
  222. }
  223. }
  224. if checked == 0 {
  225. t.Fatal("no ${{ }} value found in the bot workflows: the parse no longer reaches the prompts")
  226. }
  227. }