| 1 | package movementtest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // The rules in this file are the ones that would otherwise depend on |
| 12 | // somebody remembering them. AGENTS.md says: if you find yourself relying on |
| 13 | // remembering a rule, make it a test instead. |
| 14 | |
| 15 | // TestOnlyLoggingFileImportsSlog keeps the sensitive pair unreachable from a |
| 16 | // log call. |
| 17 | // |
| 18 | // Email and stance together are a political position attached to a named |
| 19 | // person, and carlos logs is platform-held storage that cannot be grepped |
| 20 | // clean afterwards. Pattern-matching for "a call that takes both an address |
| 21 | // and a stance" is not decidable over arbitrary Go — wrappers, structs, a %v |
| 22 | // of a Signup — so the boundary is drawn one level up: only logging.go may |
| 23 | // reach the logger at all, and its surface takes typed reason codes rather |
| 24 | // than strings or errors. |
| 25 | // |
| 26 | // cmd/ is out of scope. It logs its own startup failures before any request |
| 27 | // exists, and it has no access to a Signup. |
| 28 | func TestOnlyLoggingFileImportsSlog(t *testing.T) { |
| 29 | const pkg = "../movement" |
| 30 | |
| 31 | err := filepath.Walk(pkg, func(path string, info os.FileInfo, err error) error { |
| 32 | if err != nil { |
| 33 | return err |
| 34 | } |
| 35 | if info.IsDir() || !strings.HasSuffix(path, ".go") { |
| 36 | return nil |
| 37 | } |
| 38 | if filepath.Base(path) == "logging.go" { |
| 39 | return nil |
| 40 | } |
| 41 | src, readErr := os.ReadFile(path) |
| 42 | if readErr != nil { |
| 43 | return readErr |
| 44 | } |
| 45 | if bytes.Contains(src, []byte(`"log/slog"`)) { |
| 46 | t.Errorf("%s imports log/slog directly.\n"+ |
| 47 | "Route it through logging.go: the log surface takes typed Reason "+ |
| 48 | "codes so an address and a stance cannot meet in one line.", path) |
| 49 | } |
| 50 | return nil |
| 51 | }) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // TestTemplatesCarryNoLiteralCopy enforces the copy table. |
| 58 | // |
| 59 | // Every user-facing string resolves through internal/copy, so that the whole |
| 60 | // engine can be built and reviewed on a canary before copy review has |
| 61 | // happened — and so that unreviewed words cannot reach production by being |
| 62 | // quietly typed into markup. Markup, attribute values and template actions |
| 63 | // are fine; sentences are not. |
| 64 | func TestTemplatesCarryNoLiteralCopy(t *testing.T) { |
| 65 | dir := filepath.Join("..", "movement", "templates") |
| 66 | entries, err := os.ReadDir(dir) |
| 67 | if err != nil { |
| 68 | t.Skipf("no templates yet: %v", err) |
| 69 | } |
| 70 | |
| 71 | for _, e := range entries { |
| 72 | if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") { |
| 73 | continue |
| 74 | } |
| 75 | src, err := os.ReadFile(filepath.Join(dir, e.Name())) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | for _, run := range prose(string(src)) { |
| 80 | t.Errorf("%s contains literal copy: %q\n"+ |
| 81 | "Move it into internal/copy and render it with {{t \"some.id\"}}.", |
| 82 | e.Name(), run) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // prose returns runs of three or more consecutive words left after template |
| 88 | // actions, tags, comments and entities are removed. Three is the threshold |
| 89 | // where a string stops looking like a class name and starts looking like a |
| 90 | // sentence somebody wrote for a reader. |
| 91 | func prose(src string) []string { |
| 92 | src = strip(src, "{{", "}}") |
| 93 | src = strip(src, "<!--", "-->") |
| 94 | src = strip(src, "<", ">") |
| 95 | |
| 96 | var out []string |
| 97 | for _, line := range strings.Split(src, "\n") { |
| 98 | words := 0 |
| 99 | var run []string |
| 100 | flush := func() { |
| 101 | if words >= 3 { |
| 102 | out = append(out, strings.Join(run, " ")) |
| 103 | } |
| 104 | words, run = 0, nil |
| 105 | } |
| 106 | for _, f := range strings.Fields(line) { |
| 107 | f = strings.Trim(f, ".,:;!?\"'()") |
| 108 | if f != "" && isWord(f) { |
| 109 | words++ |
| 110 | run = append(run, f) |
| 111 | continue |
| 112 | } |
| 113 | flush() |
| 114 | } |
| 115 | flush() |
| 116 | } |
| 117 | return out |
| 118 | } |
| 119 | |
| 120 | func isWord(s string) bool { |
| 121 | for _, r := range s { |
| 122 | if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && r != '-' && r != '\'' { |
| 123 | return false |
| 124 | } |
| 125 | } |
| 126 | return true |
| 127 | } |
| 128 | |
| 129 | // strip removes every open..close span, including unterminated trailing ones. |
| 130 | func strip(s, open, close string) string { |
| 131 | var b strings.Builder |
| 132 | for { |
| 133 | i := strings.Index(s, open) |
| 134 | if i < 0 { |
| 135 | b.WriteString(s) |
| 136 | return b.String() |
| 137 | } |
| 138 | b.WriteString(s[:i]) |
| 139 | b.WriteString(" ") |
| 140 | rest := s[i+len(open):] |
| 141 | j := strings.Index(rest, close) |
| 142 | if j < 0 { |
| 143 | return b.String() |
| 144 | } |
| 145 | s = rest[j+len(close):] |
| 146 | } |
| 147 | } |
| 148 | |