Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions internal/guard/ascii_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ import (
// The command line is English only, error messages included. Only the window
// gets translations.
//
// This catches accented characters. It does not catch another language
// written in plain ASCII, and no automated check ever will - that part stays
// with reading. See docs/QUALITY.md section 8.
// This catches accented characters. It does not catch another language written
// in plain ASCII - see language_test.go, which does, for the part of it this
// project actually writes.
//
// Until 2026-09-02 the sentence here read "and no automated check ever will".
// That was a written impossibility with nothing holding it, and it was wrong in
// the way this project has recorded twice before: the limit was in the
// mechanism rather than in the problem. Asking whether a character is above 127
// cannot see a language. Asking whether a WORD is one only Polish uses can, and
// a comment in Polish had already shipped once while this sentence stood.
//
// It is still true that no check catches every language, and the reading it
// leaves to a person is smaller rather than gone. See docs/QUALITY.md section 8.
//
// Test files are exempt on purpose. Other languages are legitimate there as
// test data.
Expand Down
165 changes: 165 additions & 0 deletions internal/guard/language_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package guard

import (
"go/parser"
"go/token"
"strings"
"testing"
"unicode"
)

// What this defends. D9: what lives in the repository is English, whatever its
// audience. Comments included - the criterion is the place, not the reader.
//
// Why it needed a guard, and why this one rather than the one next door.
// ascii_test.go asks whether a character is above 127. Polish written without
// its accents is entirely ASCII, so it walks past that question, and past the
// punctuation guard, and past every linter here. Measured 2026-08-27: a
// six line Polish comment sat in internal/guard for a day and was found by
// accident, because misspell reported one word inside it as a typo. Nothing
// was looking at the other five lines, and nothing was looking at the file at
// all - ascii_test.go covers internal/cli and cmd/tfg, not this package.
//
// Why the sentence in ascii_test.go was too strong. It says no automated check
// ever will catch another language written in plain ASCII. That is a written
// impossibility with nothing holding it, which this project has been burned by
// before - the 7Z claim that four commands overturned, and the dropdown theme
// that six days of nobody rechecking left standing. The limit was in the
// MECHANISM, not in the problem: asking about characters cannot see a language,
// and asking about WORDS can. Not every language - this asks about Polish,
// because Polish is the only other language written here.
//
// Why comments and not string literals. A literal may legitimately hold another
// language as test data, which is why ascii_test.go exempts test files. A
// comment has no such excuse in any file, so this covers tests too - the defect
// that started this was in a test file.
//
// What this does NOT catch. Polish written entirely in words that are also
// English words, and every language that is not Polish. It narrows the reading
// that ascii_test.go leaves to a person, it does not remove it.

// polishWords are Polish words that are not also English words.
//
// Chosen for that property rather than for frequency, and it is the whole
// design. "to", "on", "we", "by", "do", "za", "ale", "co" and "pod" are all
// ordinary Polish and all ordinary English, so every one of them would report a
// perfectly good English comment. What is left is still dense enough that a
// sentence of Polish is very hard to write without one.
//
// Both spellings of the accented ones, because this project writes Polish both
// ways - the documents carry accents and the commit messages mostly do not.
var polishWords = map[string]bool{
"jest": true, "sie": true, "się": true, "wiec": true, "więc": true,
"ktory": true, "który": true, "ktora": true, "która": true,
"ktore": true, "które": true, "zeby": true, "żeby": true,
"dlatego": true, "poniewaz": true, "ponieważ": true, "czyli": true,
"tylko": true, "takze": true, "także": true, "wszystko": true,
"jednak": true, "przez": true, "bardzo": true, "moze": true, "może": true,
"musi": true, "trzeba": true, "wtedy": true, "zawsze": true, "nigdy": true,
"teraz": true, "nawet": true, "jako": true, "przy": true, "nad": true,
"bez": true, "dla": true, "nie": true, "oraz": true, "albo": true,
"kazdy": true, "każdy": true, "wlasnie": true, "właśnie": true,
"zamiast": true, "rzeczy": true, "byla": true, "była": true,
"bylo": true, "było": true, "mowi": true, "mówi": true, "robi": true,
"jesli": true, "jeśli": true, "kiedy": true, "gdzie": true, "wszystkie": true,
}

// Translations are the one place another language is the subject.
//
// A comment there explaining what a Polish string says would be reporting
// itself. Named rather than guessed at, so a second package holding another
// language has to be added here on purpose.
const translationsPackage = "internal/gui/text"

func TestNoCommentInTheRepositoryIsWrittenInPolish(t *testing.T) {
checked, comments := 0, 0

for _, p := range packages(t) {
if p.rel == translationsPackage || strings.HasPrefix(p.rel, translationsPackage+"/") {
continue
}

// Tests as well as sources. The comment that started this was in a test
// file, and a rule that skipped them would have been green on the day
// it was written.
for _, f := range concat(p.files, p.tests) {
checked++

fset := token.NewFileSet()
parsed, err := parser.ParseFile(fset, f, nil, parser.ParseComments)
if err != nil {
t.Errorf("parsing %s: %v", f, err)
continue
}

for _, group := range parsed.Comments {
comments++
for _, line := range group.List {
if word, found := firstPolishWord(line.Text); found {
pos := fset.Position(line.Pos())
t.Errorf("%s:%d holds the Polish word %q in a comment.\n"+
" %s\n"+
"D9 makes everything in the repository English, comments included, "+
"and the criterion is the place rather than the reader. The internal "+
"documents are Polish because they live outside the repository.",
trimRoot(t, pos.Filename), pos.Line, word, strings.TrimSpace(line.Text))
break
}
}
}
}
}

// Both counters, because either being zero means a green test about nothing.
// build.ImportDir honours build tags, so a shell with CGO_ENABLED=0 hides
// every file behind //go:build cgo - the same environment noise that makes
// the notices guard report no modules at all.
if checked == 0 {
t.Fatal("no Go file was read, so this proved nothing")
}
if comments == 0 {
t.Fatalf("%d Go files were read and not one comment was found, which means the "+
"comments were not reached rather than that they are all English", checked)
}
}

// firstPolishWord reports the first word of a comment that is Polish and not
// also English.
//
// Split on anything that is not a letter, so the comment markers, the
// punctuation and any identifier with an underscore fall apart into words
// rather than hiding one. Done this way rather than with a word boundary in a
// pattern because Go's boundaries are ASCII only, and half of these words are
// not.
func firstPolishWord(text string) (string, bool) {
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
return !unicode.IsLetter(r)
}) {
// A word in capitals is a quoted value, not prose. Two guards read the
// Polish regression table in CLAUDE.md and name its verdict column in
// their own comments - "JEST" with nothing behind it - and both were
// reported by the first version of this. A comment naming a value it
// works with is English prose about a Polish token, which is the
// opposite of what this looks for.
//
// Safe because the emphasis this project puts in comments is on English
// words. NOT, RUNS and DEFAULT collide with nothing here.
if word == strings.ToUpper(word) && word != strings.ToLower(word) {
continue
}
lowered := strings.ToLower(word)
if polishWords[lowered] {
return lowered, true
}
}
return "", false
}

func trimRoot(t *testing.T, path string) string {
t.Helper()
root := repoRoot(t)
if rel := strings.TrimPrefix(path, root); rel != path {
return strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
}
return path
}
104 changes: 104 additions & 0 deletions internal/guard/socialpicture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package guard

import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)

// What this defends. The picture other sites show when this project is shared
// says what the card says today.
//
// Why it needed a guard, and it is not a hypothetical. The card is a PAGE -
// web/templates/social.html, rendered with the facts the registry holds, so the
// number of formats on it comes from the program. The picture is a PHOTOGRAPH
// of that page, taken by hand and committed as an asset. 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.
//
// Measured 2026-09-02, and it had already happened: the committed picture said
// "21 formats" and "21 real formats" while the site said "24 real formats". The
// picture was taken on 2026-08-29 and the card was last rendered on 2026-08-31,
// when JPEG XL became the twenty fourth format. Three formats out of date, on
// the one image a stranger sees before they see anything else, for three days,
// with a green suite the whole time.
//
// How it works. The stamp beside the picture is the digest of the card the
// picture was taken of. Render the card now, hash it, compare. A template edit,
// a new format, a changed word - any of them moves the digest and this goes red
// until somebody takes the photograph again.
//
// What this does NOT check. That the picture is a photograph of THAT card
// rather than of something else - nothing here opens the PNG. A person pointing
// the camera at the wrong page would pass. It closes the drift, not the aim.
//
// Why a test cannot just take the photograph. It needs a browser, and the card
// has to be served over HTTP rather than opened as a file - it asks for its
// assets by absolute path, so under file:// the icon and the window shot are
// both missing and the result looks fine. That is why there is a probe with the
// trap written into it rather than four lines here.
const socialStampFile = "social-preview.sha256"

func TestTheSocialPictureShowsTheCardAsItIsNow(t *testing.T) {
root := webRoot(t)

picture := filepath.Join(root, "assets", "social-preview.png")
if _, err := os.Stat(picture); err != nil {
t.Fatalf("the social picture is missing: %v", err)
}

s := siteUnderTest(t)
rendered, err := s.Render()
if err != nil {
t.Fatalf("rendering the site: %v", err)
}
card, ok := rendered["social.html"]
if !ok {
t.Fatalf("the site no longer renders social.html, so there is no card to "+
"photograph. It renders: %d pages", len(rendered))
}
if len(card) == 0 {
t.Fatal("the card rendered empty, so its digest would describe nothing")
}

sum := sha256.Sum256(card)
now := hex.EncodeToString(sum[:])

stamp := filepath.Join(root, socialStampFile)
if os.Getenv("TFG_WRITE_SOCIAL_STAMP") == "1" {
if err := os.WriteFile(stamp, []byte(now+"\n"), 0o644); err != nil {
t.Fatalf("writing %s: %v", socialStampFile, err)
}
t.Logf("%s now says %s - only correct if the picture beside it was just retaken",
socialStampFile, now)
return
}

body, err := os.ReadFile(stamp)
if err != nil {
t.Fatalf("%s is missing, so nothing says which card the picture is of: %v",
socialStampFile, err)
}
was := strings.TrimSpace(string(body))
if was == "" {
t.Fatalf("%s is empty, so this would pass whatever the card says", socialStampFile)
}

if was != now {
t.Errorf("the social card has changed since the picture of it was taken.\n"+
" the picture is of: %s\n"+
" the card is now: %s\n"+
"The picture is what another site shows when somebody shares this project, and "+
"nothing else notices it is stale - the site guard copies it rather than "+
"rendering it. Measured once already: it sat three formats out of date for "+
"three days.\n"+
"Take it again, then rewrite the site and the stamp:\n"+
" python tools/probes/social-shot.py web/public web/assets/social-preview.png\n"+
" TFG_WRITE_SITE=1 go test ./internal/guard/ -run TestTheSiteSaysWhatTheToolSays\n"+
" TFG_WRITE_SOCIAL_STAMP=1 go test ./internal/guard/ -run TestTheSocialPicture",
was, now)
}
}
Binary file modified web/assets/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified web/public/assets/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions web/social-preview.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
9ef07b206eecd220eda115f5eb9b2f7beaebb02a0dfe2e9754679beb3f3f536d
Loading