diff --git a/internal/guard/ascii_test.go b/internal/guard/ascii_test.go index 9311089..015810a 100644 --- a/internal/guard/ascii_test.go +++ b/internal/guard/ascii_test.go @@ -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. diff --git a/internal/guard/language_test.go b/internal/guard/language_test.go new file mode 100644 index 0000000..8359ec6 --- /dev/null +++ b/internal/guard/language_test.go @@ -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 +} diff --git a/internal/guard/socialpicture_test.go b/internal/guard/socialpicture_test.go new file mode 100644 index 0000000..607f9e1 --- /dev/null +++ b/internal/guard/socialpicture_test.go @@ -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) + } +} diff --git a/web/assets/social-preview.png b/web/assets/social-preview.png index 06a930c..3f99eba 100644 Binary files a/web/assets/social-preview.png and b/web/assets/social-preview.png differ diff --git a/web/public/assets/social-preview.png b/web/public/assets/social-preview.png index 06a930c..3f99eba 100644 Binary files a/web/public/assets/social-preview.png and b/web/public/assets/social-preview.png differ diff --git a/web/social-preview.sha256 b/web/social-preview.sha256 new file mode 100644 index 0000000..4b2ac8d --- /dev/null +++ b/web/social-preview.sha256 @@ -0,0 +1 @@ +9ef07b206eecd220eda115f5eb9b2f7beaebb02a0dfe2e9754679beb3f3f536d