Skip to content

Commit a45795a

Browse files
donislawdevclaude
andauthored
guard: the social picture and the language of a comment both stop drifting quietly (#44)
Two blind spots, both of which had already let something through. The social picture. The card is a page, rendered with the facts the registry holds, and the picture is a photograph of it taken by hand. The site guard renders every page and compares it with what is published, and it COPIES the picture - so a card that changed and a picture that did not are both green. That had happened. The committed picture said "21 formats" and "21 real formats" while the site said "24 real formats". Taken on 2026-08-29, card last rendered on 2026-08-31 when JPEG XL became the twenty fourth format. Three formats out of date, for three days, on the one image a stranger sees before anything else. The picture is retaken and a stamp beside it holds the digest of the card it is a photograph of. Any edit to the template, any new format, any changed word moves that digest and the guard goes red. Taking it needs a probe rather than four lines, and the reason cost one wrong picture: the card asks for its assets by absolute path, so opened as a file it finds none of them. That shot came out without the chickpea and without the window, looked entirely plausible, and had the right number of formats on it - so a check asking "is this current" would have said yes. The language of a comment. D9 makes everything in the repository English, comments included. Polish written without its accents is entirely ASCII, so it walks past the ASCII guard, past the punctuation guard and past every linter - and a six line Polish comment had already sat in internal/guard for a day, found by accident when misspell reported one word inside it as a typo. ascii_test.go said no automated check ever would catch this. That was a written impossibility with nothing holding it, and the limit was in the mechanism rather than the problem: asking about characters cannot see a language, asking about words can. The sentence is corrected in place with the date and the reason. The wordlist holds Polish words that are not also English words - to, on, we, by, do, za, ale, co and pod are all left out for exactly that reason. Comments only, because a literal may legitimately hold another language as test data and a comment has that excuse in no file. A word in capitals is a quoted value rather than prose, which is what two guards naming the Polish regression table turned out to need. Both proven by mutation. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f78c720 commit a45795a

6 files changed

Lines changed: 283 additions & 3 deletions

File tree

‎internal/guard/ascii_test.go‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@ import (
1010
// The command line is English only, error messages included. Only the window
1111
// gets translations.
1212
//
13-
// This catches accented characters. It does not catch another language
14-
// written in plain ASCII, and no automated check ever will - that part stays
15-
// with reading. See docs/QUALITY.md section 8.
13+
// This catches accented characters. It does not catch another language written
14+
// in plain ASCII - see language_test.go, which does, for the part of it this
15+
// project actually writes.
16+
//
17+
// Until 2026-09-02 the sentence here read "and no automated check ever will".
18+
// That was a written impossibility with nothing holding it, and it was wrong in
19+
// the way this project has recorded twice before: the limit was in the
20+
// mechanism rather than in the problem. Asking whether a character is above 127
21+
// cannot see a language. Asking whether a WORD is one only Polish uses can, and
22+
// a comment in Polish had already shipped once while this sentence stood.
23+
//
24+
// It is still true that no check catches every language, and the reading it
25+
// leaves to a person is smaller rather than gone. See docs/QUALITY.md section 8.
1626
//
1727
// Test files are exempt on purpose. Other languages are legitimate there as
1828
// test data.

‎internal/guard/language_test.go‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package guard
2+
3+
import (
4+
"go/parser"
5+
"go/token"
6+
"strings"
7+
"testing"
8+
"unicode"
9+
)
10+
11+
// What this defends. D9: what lives in the repository is English, whatever its
12+
// audience. Comments included - the criterion is the place, not the reader.
13+
//
14+
// Why it needed a guard, and why this one rather than the one next door.
15+
// ascii_test.go asks whether a character is above 127. Polish written without
16+
// its accents is entirely ASCII, so it walks past that question, and past the
17+
// punctuation guard, and past every linter here. Measured 2026-08-27: a
18+
// six line Polish comment sat in internal/guard for a day and was found by
19+
// accident, because misspell reported one word inside it as a typo. Nothing
20+
// was looking at the other five lines, and nothing was looking at the file at
21+
// all - ascii_test.go covers internal/cli and cmd/tfg, not this package.
22+
//
23+
// Why the sentence in ascii_test.go was too strong. It says no automated check
24+
// ever will catch another language written in plain ASCII. That is a written
25+
// impossibility with nothing holding it, which this project has been burned by
26+
// before - the 7Z claim that four commands overturned, and the dropdown theme
27+
// that six days of nobody rechecking left standing. The limit was in the
28+
// MECHANISM, not in the problem: asking about characters cannot see a language,
29+
// and asking about WORDS can. Not every language - this asks about Polish,
30+
// because Polish is the only other language written here.
31+
//
32+
// Why comments and not string literals. A literal may legitimately hold another
33+
// language as test data, which is why ascii_test.go exempts test files. A
34+
// comment has no such excuse in any file, so this covers tests too - the defect
35+
// that started this was in a test file.
36+
//
37+
// What this does NOT catch. Polish written entirely in words that are also
38+
// English words, and every language that is not Polish. It narrows the reading
39+
// that ascii_test.go leaves to a person, it does not remove it.
40+
41+
// polishWords are Polish words that are not also English words.
42+
//
43+
// Chosen for that property rather than for frequency, and it is the whole
44+
// design. "to", "on", "we", "by", "do", "za", "ale", "co" and "pod" are all
45+
// ordinary Polish and all ordinary English, so every one of them would report a
46+
// perfectly good English comment. What is left is still dense enough that a
47+
// sentence of Polish is very hard to write without one.
48+
//
49+
// Both spellings of the accented ones, because this project writes Polish both
50+
// ways - the documents carry accents and the commit messages mostly do not.
51+
var polishWords = map[string]bool{
52+
"jest": true, "sie": true, "się": true, "wiec": true, "więc": true,
53+
"ktory": true, "który": true, "ktora": true, "która": true,
54+
"ktore": true, "które": true, "zeby": true, "żeby": true,
55+
"dlatego": true, "poniewaz": true, "ponieważ": true, "czyli": true,
56+
"tylko": true, "takze": true, "także": true, "wszystko": true,
57+
"jednak": true, "przez": true, "bardzo": true, "moze": true, "może": true,
58+
"musi": true, "trzeba": true, "wtedy": true, "zawsze": true, "nigdy": true,
59+
"teraz": true, "nawet": true, "jako": true, "przy": true, "nad": true,
60+
"bez": true, "dla": true, "nie": true, "oraz": true, "albo": true,
61+
"kazdy": true, "każdy": true, "wlasnie": true, "właśnie": true,
62+
"zamiast": true, "rzeczy": true, "byla": true, "była": true,
63+
"bylo": true, "było": true, "mowi": true, "mówi": true, "robi": true,
64+
"jesli": true, "jeśli": true, "kiedy": true, "gdzie": true, "wszystkie": true,
65+
}
66+
67+
// Translations are the one place another language is the subject.
68+
//
69+
// A comment there explaining what a Polish string says would be reporting
70+
// itself. Named rather than guessed at, so a second package holding another
71+
// language has to be added here on purpose.
72+
const translationsPackage = "internal/gui/text"
73+
74+
func TestNoCommentInTheRepositoryIsWrittenInPolish(t *testing.T) {
75+
checked, comments := 0, 0
76+
77+
for _, p := range packages(t) {
78+
if p.rel == translationsPackage || strings.HasPrefix(p.rel, translationsPackage+"/") {
79+
continue
80+
}
81+
82+
// Tests as well as sources. The comment that started this was in a test
83+
// file, and a rule that skipped them would have been green on the day
84+
// it was written.
85+
for _, f := range concat(p.files, p.tests) {
86+
checked++
87+
88+
fset := token.NewFileSet()
89+
parsed, err := parser.ParseFile(fset, f, nil, parser.ParseComments)
90+
if err != nil {
91+
t.Errorf("parsing %s: %v", f, err)
92+
continue
93+
}
94+
95+
for _, group := range parsed.Comments {
96+
comments++
97+
for _, line := range group.List {
98+
if word, found := firstPolishWord(line.Text); found {
99+
pos := fset.Position(line.Pos())
100+
t.Errorf("%s:%d holds the Polish word %q in a comment.\n"+
101+
" %s\n"+
102+
"D9 makes everything in the repository English, comments included, "+
103+
"and the criterion is the place rather than the reader. The internal "+
104+
"documents are Polish because they live outside the repository.",
105+
trimRoot(t, pos.Filename), pos.Line, word, strings.TrimSpace(line.Text))
106+
break
107+
}
108+
}
109+
}
110+
}
111+
}
112+
113+
// Both counters, because either being zero means a green test about nothing.
114+
// build.ImportDir honours build tags, so a shell with CGO_ENABLED=0 hides
115+
// every file behind //go:build cgo - the same environment noise that makes
116+
// the notices guard report no modules at all.
117+
if checked == 0 {
118+
t.Fatal("no Go file was read, so this proved nothing")
119+
}
120+
if comments == 0 {
121+
t.Fatalf("%d Go files were read and not one comment was found, which means the "+
122+
"comments were not reached rather than that they are all English", checked)
123+
}
124+
}
125+
126+
// firstPolishWord reports the first word of a comment that is Polish and not
127+
// also English.
128+
//
129+
// Split on anything that is not a letter, so the comment markers, the
130+
// punctuation and any identifier with an underscore fall apart into words
131+
// rather than hiding one. Done this way rather than with a word boundary in a
132+
// pattern because Go's boundaries are ASCII only, and half of these words are
133+
// not.
134+
func firstPolishWord(text string) (string, bool) {
135+
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
136+
return !unicode.IsLetter(r)
137+
}) {
138+
// A word in capitals is a quoted value, not prose. Two guards read the
139+
// Polish regression table in CLAUDE.md and name its verdict column in
140+
// their own comments - "JEST" with nothing behind it - and both were
141+
// reported by the first version of this. A comment naming a value it
142+
// works with is English prose about a Polish token, which is the
143+
// opposite of what this looks for.
144+
//
145+
// Safe because the emphasis this project puts in comments is on English
146+
// words. NOT, RUNS and DEFAULT collide with nothing here.
147+
if word == strings.ToUpper(word) && word != strings.ToLower(word) {
148+
continue
149+
}
150+
lowered := strings.ToLower(word)
151+
if polishWords[lowered] {
152+
return lowered, true
153+
}
154+
}
155+
return "", false
156+
}
157+
158+
func trimRoot(t *testing.T, path string) string {
159+
t.Helper()
160+
root := repoRoot(t)
161+
if rel := strings.TrimPrefix(path, root); rel != path {
162+
return strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
163+
}
164+
return path
165+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package guard
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// What this defends. The picture other sites show when this project is shared
13+
// says what the card says today.
14+
//
15+
// Why it needed a guard, and it is not a hypothetical. The card is a PAGE -
16+
// web/templates/social.html, rendered with the facts the registry holds, so the
17+
// number of formats on it comes from the program. The picture is a PHOTOGRAPH
18+
// of that page, taken by hand and committed as an asset. The site guard renders
19+
// every page and compares it with what is published, and it COPIES the picture,
20+
// so a card that changed and a picture that did not are both green.
21+
//
22+
// Measured 2026-09-02, and it had already happened: the committed picture said
23+
// "21 formats" and "21 real formats" while the site said "24 real formats". The
24+
// picture was taken on 2026-08-29 and the card was last rendered on 2026-08-31,
25+
// when JPEG XL became the twenty fourth format. Three formats out of date, on
26+
// the one image a stranger sees before they see anything else, for three days,
27+
// with a green suite the whole time.
28+
//
29+
// How it works. The stamp beside the picture is the digest of the card the
30+
// picture was taken of. Render the card now, hash it, compare. A template edit,
31+
// a new format, a changed word - any of them moves the digest and this goes red
32+
// until somebody takes the photograph again.
33+
//
34+
// What this does NOT check. That the picture is a photograph of THAT card
35+
// rather than of something else - nothing here opens the PNG. A person pointing
36+
// the camera at the wrong page would pass. It closes the drift, not the aim.
37+
//
38+
// Why a test cannot just take the photograph. It needs a browser, and the card
39+
// has to be served over HTTP rather than opened as a file - it asks for its
40+
// assets by absolute path, so under file:// the icon and the window shot are
41+
// both missing and the result looks fine. That is why there is a probe with the
42+
// trap written into it rather than four lines here.
43+
const socialStampFile = "social-preview.sha256"
44+
45+
func TestTheSocialPictureShowsTheCardAsItIsNow(t *testing.T) {
46+
root := webRoot(t)
47+
48+
picture := filepath.Join(root, "assets", "social-preview.png")
49+
if _, err := os.Stat(picture); err != nil {
50+
t.Fatalf("the social picture is missing: %v", err)
51+
}
52+
53+
s := siteUnderTest(t)
54+
rendered, err := s.Render()
55+
if err != nil {
56+
t.Fatalf("rendering the site: %v", err)
57+
}
58+
card, ok := rendered["social.html"]
59+
if !ok {
60+
t.Fatalf("the site no longer renders social.html, so there is no card to "+
61+
"photograph. It renders: %d pages", len(rendered))
62+
}
63+
if len(card) == 0 {
64+
t.Fatal("the card rendered empty, so its digest would describe nothing")
65+
}
66+
67+
sum := sha256.Sum256(card)
68+
now := hex.EncodeToString(sum[:])
69+
70+
stamp := filepath.Join(root, socialStampFile)
71+
if os.Getenv("TFG_WRITE_SOCIAL_STAMP") == "1" {
72+
if err := os.WriteFile(stamp, []byte(now+"\n"), 0o644); err != nil {
73+
t.Fatalf("writing %s: %v", socialStampFile, err)
74+
}
75+
t.Logf("%s now says %s - only correct if the picture beside it was just retaken",
76+
socialStampFile, now)
77+
return
78+
}
79+
80+
body, err := os.ReadFile(stamp)
81+
if err != nil {
82+
t.Fatalf("%s is missing, so nothing says which card the picture is of: %v",
83+
socialStampFile, err)
84+
}
85+
was := strings.TrimSpace(string(body))
86+
if was == "" {
87+
t.Fatalf("%s is empty, so this would pass whatever the card says", socialStampFile)
88+
}
89+
90+
if was != now {
91+
t.Errorf("the social card has changed since the picture of it was taken.\n"+
92+
" the picture is of: %s\n"+
93+
" the card is now: %s\n"+
94+
"The picture is what another site shows when somebody shares this project, and "+
95+
"nothing else notices it is stale - the site guard copies it rather than "+
96+
"rendering it. Measured once already: it sat three formats out of date for "+
97+
"three days.\n"+
98+
"Take it again, then rewrite the site and the stamp:\n"+
99+
" python tools/probes/social-shot.py web/public web/assets/social-preview.png\n"+
100+
" TFG_WRITE_SITE=1 go test ./internal/guard/ -run TestTheSiteSaysWhatTheToolSays\n"+
101+
" TFG_WRITE_SOCIAL_STAMP=1 go test ./internal/guard/ -run TestTheSocialPicture",
102+
was, now)
103+
}
104+
}

‎web/assets/social-preview.png‎

198 Bytes
Loading
198 Bytes
Loading

‎web/social-preview.sha256‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9ef07b206eecd220eda115f5eb9b2f7beaebb02a0dfe2e9754679beb3f3f536d

0 commit comments

Comments
 (0)