From 7328400d4c8e455f57a2a56cd4b378c032418669 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 7 Sep 2026 18:27:53 +0200 Subject: [PATCH 1/2] fix: the window says when a run's record will be too big to read back The command line has warned about this since the ceiling was measured on 2026-08-26. The window said nothing at all: TooLargeToReadBack had two callers and not one of them was in internal/gui. So somebody who generated 25 000 files from the window ended up with a directory that tfg verify and tfg cleanup both refuse, and no warning anywhere. The manifest is the only authority over what may be removed, so those files could never be cleaned up by this tool again. It is the kind of parity gap D1 loses most easily. Not something the engine can do from one surface and not the other, which is what the parity guard looks for, but something one surface SAYS and the other does not - and both surfaces reach the same engine, so nothing there could see it. The observation recorded this as a question about the manifest schema, on the grounds that notes are per file and this one is per run. That premise was false, and finding out was most of the work. The command line does not read this off the manifest either: it works it out from the plan and prints it before the first byte. manifest.TooLargeToReadBack was put where it is exactly so the two surfaces could not come to different conclusions about one run. What was missing was a caller. The schema does not move and manifest_version stays 1.0. One method arrives, (*Manifest).ReadBackReach, because the count of entries carrying a note is already kept while entries are added. Working it out a second time would mean walking every file again and getting it subtly wrong when a failed entry gains a note of its own. The window says it in two places, because it cannot say anything in the middle of a run - a widget touched from a worker is a race, and two of those were found on CI: - after Preview, which is the window's answer to --dry-run. engine.Run with DryRun builds the whole document, so the answer is there for the asking. - when a run finishes, immediately after the line saying what the run did and ahead of any other note. This is the one that matters: Preview is a button somebody may never press, and that person is the one left with the directory. Four guards, four mutations. The pair for Preview was written wrong first and the probe said so. It set a 200 B size while the window was on its default format, which is the first in the registry - avif - so both previews were REFUSED. The negative half passed while proving nothing, because a screen saying "check the settings marked above" says nothing about a manifest ceiling either. Both now choose txt and both assert the preview was accepted before reading anything into what it said. The guard for a finished run costs 26 s, because it really writes 22 345 files. runFinished has no cheaper seam, and this is the only guard covering the case the observation is actually about. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 ++ internal/guard/windowmanifestreach_test.go | 222 +++++++++++++++++++++ internal/gui/text/locale/en.json | 4 + internal/gui/text/text.go | 17 ++ internal/gui/window/run.go | 24 ++- internal/gui/window/runreport.go | 32 +++ internal/manifest/manifest.go | 20 ++ 7 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 internal/guard/windowmanifestreach_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e2de7fd..7a4e4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -299,6 +299,22 @@ because it turns other people's test suites red. ### Fixed +- **The window now warns when a run's record will be too big for this build to + read back.** The command line has said this since the ceiling was measured. + The window said nothing at all, so somebody who generated 25 000 files from it + was left with a directory that `tfg verify` and `tfg cleanup` both refuse - + and the manifest is the only authority over what may be removed, so those + files could never be cleaned up by this tool again. + + It appears in two places, because the window cannot speak in the middle of a + run: under Preview, which is the window's answer to `--dry-run`, and again + when a run finishes. The second is the one that matters, since Preview is a + button somebody may never press. It comes straight after the line saying what + the run did, ahead of any other note. + + The run itself still works and is still not refused. What was missing was that + nobody was told. + - **`tfg verify` no longer calls another run's files "extra".** A directory is allowed to hold more than one run - that is what `output.manifest` is for - and verifying one of them reported every file the other had written as a file diff --git a/internal/guard/windowmanifestreach_test.go b/internal/guard/windowmanifestreach_test.go new file mode 100644 index 0000000..cbecd4b --- /dev/null +++ b/internal/guard/windowmanifestreach_test.go @@ -0,0 +1,222 @@ +package guard + +import ( + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" +) + +// The window says the same thing the command line says about a record too big +// to read back. +// +// Observation O184, measured on 2026-09-06: TooLargeToReadBack had two callers, +// both in internal/cli and internal/manifest, and NOT ONE in internal/gui. A +// person who generated 25 000 files from the window was told nothing at all, +// and was left with a directory that tfg verify and tfg cleanup both refuse - +// the manifest is the only authority over what may be removed, so a manifest +// that cannot be read is a set of files with no owner. +// +// It is the kind of parity gap D1 loses most easily. Not something the engine +// can do from one surface and not the other, which is what the parity guard +// looks for, but something one surface SAYS and the other does not. +// +// The note was written down as a question about the manifest schema, on the +// grounds that notes are per file and this one is per run. It is not. The +// command line does not read this off the manifest either - it works it out +// from the plan and prints it before the first byte - and manifest.TooLarge- +// ToReadBack was put where it is exactly so the two surfaces could not come to +// different conclusions about one run. What was missing was a caller. + +// overTheCeiling is a file count whose manifest this build would refuse. +// +// Worked out from the estimate rather than written here, for the reason the +// command line guard beside it gives: a guard carrying its own copy of a limit +// goes stale the day somebody changes the real one, and says nothing while it +// does. +func overTheCeiling(t *testing.T) int { + t.Helper() + over := int(manifest.MaxBytes/manifest.BytesPerEntry) + 1000 + if _, tooBig := manifest.TooLargeToReadBack(over, 0); !tooBig { + t.Fatalf("%d entries was not judged too large, so this guard would prove nothing", over) + } + return over +} + +// previewOf presses Preview for a run of count files and gives back what the +// screen said. +// +// It REFUSES to return a refusal, and that is the whole reason it exists. The +// first version of the pair below set a size the default format will not take - +// the window opens on the first format in the registry, which is avif, and +// 200 B is far under what a picture needs. Both previews were turned down, so +// the negative half passed while proving nothing: a screen that says "check the +// settings marked above" says nothing about a manifest ceiling either. +func previewOf(t *testing.T, count string) string { + t.Helper() + content, w, host := screenInAWindowWithHost(t, text.TabOneTarget()) + + // txt rather than whatever the window opens on, for two reasons. The size + // below has to be one the format takes, and planning twenty two thousand + // pictures would encode twenty two thousand pictures - png, jpg, gif and + // avif all do that while planning. + picker, ok := controlUnder(content, text.FieldFormat()).(*parts.Chooser) + if !ok { + t.Fatal("the format field is not a list to choose from, so this guard read the wrong tree") + } + picker.SetSelected("txt") + + // Small files, because what is being asked about is the number of ENTRIES + // rather than the number of bytes. A preview writes nothing either way. + fill(t, content, text.FieldSize(), "200b") + fill(t, content, text.FieldCount(), count) + + press(t, content, text.ButtonPreview()) + // This preview is accepted, so it answers from a worker. Joined before the + // status line is read - see join. + join(host) + settle(content, w) + + _, status := runMessages(content) + if status == nil { + t.Fatal("the screen has no status line, so this guard read the wrong tree") + } + // Matched on the tail of the preview's own sentence, the way the action bar + // guard does it, so this cannot be satisfied by a refusal. + marker := text.PreviewCost(1, nil, "1 B") + tail := marker[strings.LastIndex(marker, " ")+1:] + if !strings.Contains(status.Text, tail) { + t.Fatalf("the preview of %s files was not accepted, so nothing here was asked about the manifest.\nIt said:\n%s", + count, status.Text) + } + return status.Text +} + +// runOf presses Generate rather than Preview, and gives back what the screen +// said when it finished. +// +// It exists because the preview is OPTIONAL. Somebody who presses Generate +// straight away never sees the preview's answer, and that person is exactly the +// one observation O184 is about - they end up with a directory nothing in this +// toolset can read or clean. The window cannot say anything in the middle of a +// run, so the end of the run is the only place left. +// +// It writes files, which is why this is the one guard here that does. Twenty +// two thousand of them at 200 B, into a directory that goes away with the test. +func runOf(t *testing.T, count string) string { + t.Helper() + content, w, host := screenInAWindowWithHost(t, text.TabOneTarget()) + + picker, ok := controlUnder(content, text.FieldFormat()).(*parts.Chooser) + if !ok { + t.Fatal("the format field is not a list to choose from, so this guard read the wrong tree") + } + picker.SetSelected("txt") + fill(t, content, text.FieldSize(), "200b") + fill(t, content, text.FieldCount(), count) + fill(t, content, text.FieldOutputDir(), t.TempDir()) + + press(t, content, text.ButtonGenerate()) + join(host) + settle(content, w) + + _, status := runMessages(content) + if status == nil { + t.Fatal("the screen has no status line, so this guard read the wrong tree") + } + // The run has to have HAPPENED. A refused run says nothing about a + // manifest either, and a guard that cannot tell those apart is the shape + // this project has recorded as passing without reaching the code. + if !strings.Contains(status.Text, text.Written(0)[strings.LastIndex(text.Written(0), " ")+1:]) { + t.Fatalf("the run of %s files did not finish, so nothing here was asked about the manifest.\nIt said:\n%s", + count, status.Text) + } + return status.Text +} + +// warningAbout is the fixed half of the sentence, without the two numbers. +// +// Taken from the text package rather than typed here, so a reworded warning +// does not quietly stop being checked. +func warningAbout(t *testing.T) string { + t.Helper() + marker := text.ManifestTooLargeToRead("SIZE", "LIMIT") + at := strings.Index(marker, "SIZE") + if at < 0 { + t.Fatal("the warning does not carry the size it was given, so this guard cannot find its fixed half") + } + return marker[:at] +} + +func TestTheWindowSaysWhenItsManifestWillBeTooBigToReadBack(t *testing.T) { + said := previewOf(t, itoa(overTheCeiling(t))) + if !strings.Contains(said, warningAbout(t)) { + t.Errorf("a preview of %d files said nothing about the record being too big to read back.\n"+ + "The command line has said this since 2026-08-26. Somebody who does the same from the window "+ + "gets a directory that neither Verify nor Clean up can read, and no warning.\nIt said:\n%s", + overTheCeiling(t), said) + } +} + +// And a run that was never previewed says it too, which is the case that +// matters most. +// +// The preview is a button somebody may not press. The warning has to reach the +// person who pressed Generate and nothing else, because they are the one left +// with the directory. +func TestAFinishedRunInTheWindowSaysItsManifestIsTooBigToReadBack(t *testing.T) { + said := runOf(t, itoa(overTheCeiling(t))) + if !strings.Contains(said, warningAbout(t)) { + t.Errorf("a finished run of %d files said nothing about the record being too big to read back.\n"+ + "That directory now has a manifest neither Verify nor Clean up can read, and nobody was told.\nIt said:\n%s", + overTheCeiling(t), said) + } + // The line somebody pressed the button for stays first. The room for these + // messages is a ceiling and the message scrolls inside it. + if first := strings.SplitN(said, "\n", 2)[0]; strings.Contains(first, warningAbout(t)) { + t.Errorf("the warning took the first line from the outcome:\n%s", said) + } +} + +// A run this build CAN read back stays quiet. +// +// Without this the guard above passes on a window that warns about every run, +// which teaches somebody to stop reading the line - the same reason the command +// line has this pair rather than only the first half. +func TestAnOrdinaryPreviewSaysNothingAboutTheManifestCeiling(t *testing.T) { + said := previewOf(t, "100") + if strings.Contains(said, warningAbout(t)) { + t.Errorf("a hundred files drew the warning about the record being too big:\n%s", said) + } +} + +// Both surfaces judge the same run the same way. +// +// The window reads the answer off the document a dry run builds, and the +// command line works it out from the plan before anything is written. Two paths +// to one number, and what makes two paths acceptable is that they go through +// one predicate. Asked at the boundary, which is the only place a disagreement +// would show. +func TestBothSurfacesJudgeTheSameRunTheSameWay(t *testing.T) { + for _, entries := range []int{ + int(manifest.MaxBytes / manifest.BytesPerEntry), + int(manifest.MaxBytes/manifest.BytesPerEntry) + 1, + } { + _, fromThePlan := manifest.TooLargeToReadBack(entries, 0) + + m := manifest.New("testing-files-generator", "0.0.0-dev", "run_x", "tfg generate", 1, "linux", "amd64") + for i := 0; i < entries; i++ { + m.Add(manifest.File{Path: "f.txt", Materialized: true}) + } + _, fromTheDocument := m.ReadBackReach() + + if fromThePlan != fromTheDocument { + t.Errorf("at %d entries the plan says %v and the document says %v.\n"+ + "The command line answers from the first and the window from the second, so one run "+ + "would be warned about on one surface and not on the other", + entries, fromThePlan, fromTheDocument) + } + } +} diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index 3b8d654..c154a75 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -248,6 +248,10 @@ "description": "Shown in the window. Carries one value, {{.Path}}, which has to stay spelled exactly that way.", "other": "the files were written and the manifest could not be saved to {{.Path}}" }, + "ManifestTooLargeToRead": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Size}}, {{.Limit}}.", + "other": "this run's record is about {{.Size}} and this build reads at most {{.Limit}}, so Verify and Clean up will not be able to read it. Split the run to keep each record readable." + }, "NotAWholeNumber": { "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Field}}, {{.Value}}.", "other": "{{.Field}} is {{.Value}}, which is not a whole number. Write the digits out, such as 1 or 500" diff --git a/internal/gui/text/text.go b/internal/gui/text/text.go index 597f319..88db185 100644 --- a/internal/gui/text/text.go +++ b/internal/gui/text/text.go @@ -96,6 +96,23 @@ func PreviewCost(count int, formats []string, total string) string { say("PreviewNothingWritten", "nothing written yet") } +// ManifestTooLargeToRead is said when a run will write a record this build +// cannot read back. +// +// The command line has printed this since 2026-08-26 and the window said +// nothing at all, which is the parity gap observation O184 names. A person who +// generates 25 000 files from a window gets a directory that tfg verify and +// tfg cleanup both refuse - and the manifest is the only authority over what +// may be deleted, so nothing in this toolset can ever remove those files. +// +// A note rather than a refusal, which is the owner's decision from that day and +// is unchanged here. The run works. What was missing was that nobody was told. +func ManifestTooLargeToRead(size, limit string) string { + return sayf("ManifestTooLargeToRead", + "this run's record is about {{.Size}} and this build reads at most {{.Limit}}, so Verify and Clean up will not be able to read it. Split the run to keep each record readable.", + map[string]any{"Size": size, "Limit": limit}) +} + // PreviewFreeSpace follows PreviewCost when the disk could be measured. It is // a separate fact because a disk we cannot read has to say nothing at all // rather than invent a number. diff --git a/internal/gui/window/run.go b/internal/gui/window/run.go index aeade27..f66f6c6 100644 --- a/internal/gui/window/run.go +++ b/internal/gui/window/run.go @@ -664,25 +664,30 @@ func (r *runner) onPreview() { // Do rather than DoAndWait, for the same reason startRun gives: the // interface thread must never be left waiting on a worker. r.holdBeforeFinishing() - fyne.Do(func() { r.previewFinished(nil, opt, planErr) }) + fyne.Do(func() { r.previewFinished(nil, nil, opt, planErr) }) close(done) return } - _, runErr := engine.Run(ctx, planned, opt) + res, runErr := engine.Run(ctx, planned, opt) r.holdBeforeFinishing() - fyne.Do(func() { r.previewFinished(planned, opt, runErr) }) + fyne.Do(func() { r.previewFinished(res, planned, opt, runErr) }) close(done) }() } // previewFinished is the end of a preview, back on the interface thread. -func (r *runner) previewFinished(planned []engine.PlannedFile, opt engine.Options, runErr error) { +// +// The result is carried across as well as the plan, and that is what lets a +// preview warn about a record too big to read back. A dry run builds the whole +// document - see manifestReachNote - so the answer is there for the asking +// rather than something the window would have to work out for itself. +func (r *runner) previewFinished(res *engine.Result, planned []engine.PlannedFile, opt engine.Options, runErr error) { r.setBusy(false, false) if runErr != nil { r.refuse(runErr) return } - r.say(previewText(planned, opt.OutDir)) + r.say(append([]string{previewText(planned, opt.OutDir)}, manifestReachNote(res)...)...) } // formatsOf is what kinds of file the run would produce, each named once. @@ -837,7 +842,14 @@ func (r *runner) runFinished(res *engine.Result, runErr, saveErr error) { // and not only in the manifest - "the manifest says which ones" is an // answer in a terminal and an instruction to open a file with ten thousand // entries in a window. - r.say(append([]string{outcomeText(res, runErr)}, notesOf(res)...)...) + // + // The warning about a record too big to read back comes SECOND, ahead of + // the per file notes, and that order is the same lesson the command line + // learned on 2026-09-06: it is the one line standing between somebody and a + // directory nothing in this toolset can ever clean up, and it was being + // buried under notes about a label that did not fit. + said := append([]string{outcomeText(res, runErr)}, manifestReachNote(res)...) + r.say(append(said, notesOf(res)...)...) r.toneOfOutcome(res, runErr) r.offerTheFolder(res) } diff --git a/internal/gui/window/runreport.go b/internal/gui/window/runreport.go index 5b1a947..baecbd4 100644 --- a/internal/gui/window/runreport.go +++ b/internal/gui/window/runreport.go @@ -7,6 +7,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" ) // What a run tells the person while it goes and when it ends. @@ -31,6 +32,37 @@ func previewText(planned []engine.PlannedFile, outDir string) string { return line } +// manifestReachNote is the window's half of the warning the command line prints +// before the first byte. +// +// Observation O184: the command line has said this since 2026-08-26 and the +// window said nothing at all, so a person generating 25 000 files from a window +// was left with a directory that neither Verify nor Clean up can read - and the +// manifest is the only authority over what may be removed. A parity gap in +// quality rather than in what the engine can do, which is the kind D1 is +// easiest to lose. +// +// Read off the document rather than worked out here, and off the SAME predicate +// the command line uses, which is what manifest.TooLargeToReadBack exists for. +// The two surfaces cannot come to different conclusions about one run. +// +// A preview reaches this too. engine.Run with DryRun adds an entry for every +// planned file, so the document a preview produces is the document the run +// would produce, minus the bytes on the disk. That is why one shape serves +// both, and why the window can answer before anything is written even though it +// cannot say a word in the middle of a run. +func manifestReachNote(res *engine.Result) []string { + if res == nil || res.Manifest == nil { + return nil + } + size, over := res.Manifest.ReadBackReach() + if !over { + return nil + } + return []string{text.ManifestTooLargeToRead( + core.HumanBytes(size), core.HumanBytes(manifest.MaxBytes))} +} + // progressText is the line under the bar. Bytes rather than files, because one // large file is a run where the file count says nothing for minutes. func progressText(p engine.Progress, elapsed time.Duration) string { diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index a2b5744..370ff70 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -527,6 +527,26 @@ func TooLargeToReadBack(entries, withNotes int) (int64, bool) { return n, n > MaxBytes } +// ReadBackReach is what this document will weigh and whether this build would +// refuse to read it. +// +// The same question TooLargeToReadBack answers, asked of a manifest that +// exists rather than of a plan that has not run. It is here because the two +// callers had nowhere else to get the count of entries carrying a note from - +// that is kept while entries are added, so working it out again would mean +// walking every file a second time and getting it subtly wrong when a failed +// entry gains a note of its own. +// +// The window needs this and the command line does not. The command line says +// its piece BEFORE the first byte, from the plan, where no manifest exists yet. +// A window cannot say anything in the middle of a run - a widget touched from a +// worker is a race, and two of those were found on CI - so it says it when the +// run ends, off the document the run actually produced. Both go through +// TooLargeToReadBack, which is where that answer is settled for both surfaces. +func (m *Manifest) ReadBackReach() (int64, bool) { + return TooLargeToReadBack(len(m.Files), m.notedFiles) +} + // TooLargeError is returned for a manifest past MaxBytes. type TooLargeError struct { Path string From c3c41bd50b063300d9f276f4477e6338b6f9dbb6 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 7 Sep 2026 18:48:18 +0200 Subject: [PATCH 2/2] docs: a contributing guide, held to the same gates as the rest of the prose The repository had a code of conduct, a security policy, issue forms and CODEOWNERS, and nothing telling somebody what a change has to satisfy before it can go in. GitHub shows this file in two more places than those four - the new issue form and the new pull request form - so it is the first thing a contributor reads. It is written to save an evening rather than to be complete. The things in it are the ones that are unusual here and would waste somebody's time if they found out afterwards: the size is exact or it is an error, the bytes of a generated file are a contract, nothing is written over, silence is banned, there is no outgoing connection ever, a new dependency needs a licence check, and every change in behaviour comes with a test that fails without it. Translations get their own section, because that is the contribution this project can take from somebody who does not write Go. It says where the catalogue lives, that the description field is generated and not to be translated, that a placeholder has to stay spelled the way it is, and that a language may carry more plural forms than English does. It also says the thing that would waste the evening: the window does not offer a language switch yet, so a contributed catalogue ships and English is still what it answers in. Saying so is the point of the section. FOUR EXISTING GUARDS NOW READ IT, rather than it getting one of its own: - englishFiles, so ASCII only, flat hyphens and no semicolons. - TestTheInstallInstructionsCarryTheBuildTags. A guide telling somebody to build without the tag hands them a binary whose AVIF encoder reads past the end of a buffer. - TestTheBuildFloorIsThePinnedToolchainAndTheDocumentsSaySo, renamed from ...AndTheReadmeSaysSo because it is no longer only the readme. Three copies of the minimum Go version already existed and were guarded. A fourth without a guard would be the one that rots. - TestNothingThatDirectsAPersonPointsAtAnotherProject. These files get written by copying one from another project, and a link left pointing at the old one reads perfectly. That is not hypothetical here - it is what the guard was written for. Two of those took a shared list of the documents that teach somebody how to build, and the third took a filename parameter, so none of them names README twice. Four mutations, all caught: the tag dropped from the build command, a Go version go.mod does not admit, an issue link pointing at the sibling project, and a semicolon. No changelog entry. That file is for changes somebody running tfg would notice, and this is not one. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 142 ++++++++++++++++++++++++++++++ README.md | 4 + internal/guard/ascii_test.go | 4 + internal/guard/buildfloor_test.go | 43 +++++---- internal/guard/buildtags_test.go | 48 ++++++---- internal/guard/community_test.go | 9 +- 6 files changed, 216 insertions(+), 34 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..816a27d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,142 @@ +# Contributing + +Thank you for looking. This is a small project with a narrow purpose, so the +most useful thing this file can do is tell you, before you spend an evening on +something, what it will and will not take. + +Everything here runs on your own machine. There is no server, no account and no +telemetry, and that is a design decision rather than a stage the project has not +reached yet. + +## The quickest ways to help + +- **Report what broke.** A file that a real reader would not open, a size that + came out wrong, a message that sent you the wrong way. The issue forms are at + [Issues](https://github.com/donislawdev/TestingFilesGenerator/issues/new/choose). +- **Translate the window.** See [Translations](#translations) below. No Go + needed. +- **Say what a manifest should have told you.** This tool exists to say how a + system under test ought to react to a file. If it stayed quiet about something + you had to work out by hand, that is worth an issue. +- **Send code.** See below for what it has to satisfy. + +## Building it + +Needs Go 1.27.0 or newer, and nothing else for the command line tool. + +``` +git clone https://github.com/donislawdev/TestingFilesGenerator +cd TestingFilesGenerator +go build -tags "$(cat .github/build-tags)" ./cmd/tfg +go test -tags "$(cat .github/build-tags)" ./... +``` + +**The build tag is not optional and it is not decoration.** A build without it +does not compile and says why. It turns off an assembly path in the AVIF encoder +that reads past the end of a buffer, and the files produced are the same either +way. Read the tag from the file rather than typing it, because it is the file +the workflows read too. + +The desktop window is a second binary, `./cmd/tfg-gui`. It draws through OpenGL +and reaches it through C, so that one needs a C compiler and is built natively +on each system. Built without one it still compiles, and says on start that it +has no window in it and that everything is on the command line. + +The test suite takes a few minutes. Most of it is one package, `internal/guard`, +which is where every test that defends a promise of the product lives. + +## Translations + +The window reads its words from a catalogue, and English is one file in it: +`internal/gui/text/locale/en.json`. To add a language, copy that file to +`.json` next to it, using the language code, and translate the values. + +```json +"ButtonCancel": { + "description": "The words on a button.", + "other": "Cancel" +} +``` + +Four things to know before you start: + +- **Translate `other`, and leave `description` alone.** The description is a note + to you about where the words appear, and it is generated from the source. +- **A placeholder such as `{{.Count}}` has to stay spelled exactly that way.** + It is where a number or a name is put in. You may move it inside the sentence. +- **Plural forms are yours to decide.** English needs `one` and `other`. If your + language needs `few`, `many` or `zero`, add them beside the English ones. The + code hands over the number and both English forms, so a language with more + forms than English is not something you have to work around. +- **Anything you leave out falls back to English**, so a half finished + catalogue leaves English sentences rather than empty ones. + +**One limit, said plainly so you do not waste an evening: the window does not +offer a language switch yet.** A catalogue you contribute is built into the +program and is loaded, and English is still what it answers in, until the +setting that chooses a language lands. If you would rather wait for that, say so +in an issue and it will be weighed as a reason to do it sooner. + +The command line is **English only** and stays that way, translations included. +It goes into scripts and CI logs that other people read, and a message that +changes language with the machine it ran on is a message nobody can search for. + +## What a change has to satisfy + +These are not style preferences. Each one is a promise the tool makes to people +whose test suites depend on it. + +- **A file is the size that was asked for, to the byte, or it is an error.** + Never a size close to it, never a quiet rounding. A batch of ten thousand + files is not something anybody checks by hand. +- **The same recipe and the same seed produce the same bytes**, on every + operating system. Anything that moves the bytes of a generated file is a + breaking change, because it turns other people's test suites red. It is + allowed, and it needs a note in the changelog and a version bump, and the + version is bumped by the maintainer rather than in the pull request. +- **Nothing is written over, and nothing is deleted that a manifest does not + list.** This tool runs in directories that belong to other people. +- **Silence is banned.** A file that was not produced, a name the filesystem + would not take, a limit the tool invented for you - each of those has to be + visible in the output and in the manifest. A manifest that quietly skipped ten + files looks complete and becomes a false result in somebody's test run. +- **No outgoing network connection, ever.** No telemetry, no update check, no + fetching anything while it runs. A test checks this by asking the compiler + what the binaries link, so a pull request that adds it will not get past CI. +- **A new dependency needs a reason and a licence check.** The project is + GPL-3.0, which rules some licences out, and the command line binary currently + has two dependencies in total. Say in the pull request why the standard + library will not do. +- **Every change in behaviour comes with a test that fails without it.** A green + suite is not evidence on its own. The question to answer in the pull request + is "which test would go red if this change were undone". +- **Words a user reads are English, with a flat hyphen and no semicolons.** That + covers the README, the changelog, `--help`, every message, and every comment + in the code. A test enforces the punctuation. +- **An error message says four things**: what happened, why, what value would be + accepted, and what to do instead. + +## Sending a pull request + +- Branch from `main` and open the pull request against `main`. Nothing is pushed + to `main` directly, including by the maintainer. +- Keep it to one subject. A pull request that fixes a bug and tidies three + unrelated files is one that cannot be reviewed or reverted cleanly. +- CI has to be green. It builds and tests on Windows, Linux and macOS, runs the + linters, and checks the dependency and supply chain gates. +- The commit message and the pull request are public and permanent. Do not put + anything in them you would not show a stranger. +- Pull requests are merged by the maintainer, as a squash. + +## Security + +Do not open an issue for a vulnerability. [SECURITY.md](SECURITY.md) says how to +report one privately and what is in scope. + +## Conduct and licence + +By taking part you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). + +Contributions are licensed under **GPL-3.0-or-later**, the same licence as the +rest of the project. Files the tool generates are yours, and carry no licence +from us at all - run `tfg license` and it says so. diff --git a/README.md b/README.md index c7dc375..fce881d 100644 --- a/README.md +++ b/README.md @@ -711,6 +711,10 @@ Found a problem or want a format? The [issue tracker](https://github.com/donislawdev/TestingFilesGenerator/issues) is open, and so is the discussion about what gets built next. +Want to help? [CONTRIBUTING.md](CONTRIBUTING.md) says what a change has to +satisfy before it can go in, and how to translate the window into another +language without writing any Go. + ## 🔒 Everything inside a generated file is made up The contents are synthesised from a seed. Names, addresses, e-mail addresses, IP diff --git a/internal/guard/ascii_test.go b/internal/guard/ascii_test.go index 015810a..6e4a6cc 100644 --- a/internal/guard/ascii_test.go +++ b/internal/guard/ascii_test.go @@ -50,6 +50,10 @@ func asciiRequired(rel string) bool { var englishFiles = []string{ "README.md", "CHANGELOG.md", + // Added 2026-09-07 with the file itself. It is repository text somebody + // reads before they have a checkout, which is the same criterion as the two + // above - the place, not the reader. + "CONTRIBUTING.md", // The notices that travel with a release binary. Added 2026-08-04 with the // file itself: it is repository text a user reads, so it belongs here by // the same criterion as the two above. The licence texts it quotes sit in diff --git a/internal/guard/buildfloor_test.go b/internal/guard/buildfloor_test.go index 85a98f4..42d7def 100644 --- a/internal/guard/buildfloor_test.go +++ b/internal/guard/buildfloor_test.go @@ -36,9 +36,10 @@ import ( // directive came out. So the pin now lives in GO_VERSION in the workflows, and // this holds the floor against every one of them. // -// The third copy is the one a person reads before they have the repository. -// README.md states the minimum in prose, and prose is the copy that rots, so it -// is held here too. Same arrangement as the build tags in +// The remaining copies are the ones a person reads before they have the +// repository. README.md states the minimum in prose, CONTRIBUTING.md states it +// again since 2026-09-07, and prose is the copy that rots - so both are held +// here. Same arrangement as the build tags in // TestTheInstallInstructionsCarryTheBuildTags and for the same reason: one file // holds the fact, everything inside a checkout reads it, and the copies that // have to live outside get a guard instead. @@ -48,7 +49,7 @@ import ( // strings is precisely where this class of defect hides. Two strings either // match or they do not, and that is checkable without a comparison anybody has // to be right about. -func TestTheBuildFloorIsThePinnedToolchainAndTheReadmeSaysSo(t *testing.T) { +func TestTheBuildFloorIsThePinnedToolchainAndTheDocumentsSaySo(t *testing.T) { root := repoRoot(t) floor := goModFloor(t, root) @@ -76,13 +77,19 @@ func TestTheBuildFloorIsThePinnedToolchainAndTheReadmeSaysSo(t *testing.T) { "floor. That is where the pin lives now that go.mod has no toolchain directive.") } - stated, at, text := readmeMinimumGo(t, root) - if stated != floor { - t.Errorf("README.md line %d tells somebody they need Go %s and go.mod admits Go %s:\n"+ + // Every document that states the requirement, not only the shop window. + // CONTRIBUTING.md states it too since 2026-09-07, and a second copy of a + // number is a second thing to keep in step. + for _, name := range filesThatTellSomebodyHowToBuild { + stated, at, text := minimumGoIn(t, root, name) + if stated == floor { + continue + } + t.Errorf("%s line %d tells somebody they need Go %s and go.mod admits Go %s:\n"+ " %s\n"+ "What to do: make the sentence name %s. It is read before anybody has the "+ "repository, so it is the one copy nothing else can correct.", - at, stated, floor, strings.TrimSpace(text), floor) + name, at, stated, floor, strings.TrimSpace(text), floor) } } @@ -123,17 +130,17 @@ func workflowFiles(t *testing.T, root string) []string { return found } -// readmeMinimumGo returns the version README.md names as the minimum, the line -// it sits on and that line's text. +// minimumGoIn returns the version a document names as the minimum, the line it +// sits on and that line's text. // // Matched on the sentence rather than on a position, because a position moves // the first time somebody adds a paragraph above it. -func readmeMinimumGo(t *testing.T, root string) (string, int, string) { +func minimumGoIn(t *testing.T, root, name string) (string, int, string) { t.Helper() - raw, err := os.ReadFile(filepath.Join(root, "README.md")) + raw, err := os.ReadFile(filepath.Join(root, name)) if err != nil { - t.Fatalf("reading README.md: %v", err) + t.Fatalf("reading %s: %v", name, err) } want := regexp.MustCompile(`Needs Go ([0-9][0-9.]*)`) @@ -146,15 +153,15 @@ func readmeMinimumGo(t *testing.T, root string) (string, int, string) { // A second sentence naming a version is two answers to one question, // and the guard would then check whichever came last. if found != "" { - t.Fatalf("README.md names a minimum Go version twice, on lines %d and %d, so "+ - "there is no single sentence to hold against go.mod", at, i+1) + t.Fatalf("%s names a minimum Go version twice, on lines %d and %d, so "+ + "there is no single sentence to hold against go.mod", name, at, i+1) } found, at, text = m[1], i+1, line } if found == "" { - t.Fatal(`README.md has no "Needs Go " sentence, so this guard checked ` + - "nothing. It is the only statement of the requirement somebody sees before " + - "they clone.") + t.Fatalf(`%s has no "Needs Go " sentence, so this guard checked `+ + "nothing. It is a statement of the requirement somebody sees before "+ + "they clone.", name) } return found, at, text } diff --git a/internal/guard/buildtags_test.go b/internal/guard/buildtags_test.go index 2819975..444763c 100644 --- a/internal/guard/buildtags_test.go +++ b/internal/guard/buildtags_test.go @@ -180,21 +180,42 @@ func TestABuildWithoutTheBuildTagsRefusesAndSaysWhy(t *testing.T) { } } -// The install instructions carry the tags the build needs. +// filesThatTellSomebodyHowToBuild are the ones carrying a build command a +// person types before they have a checkout. +// +// Everything this project runs reads the tags from one file. That cannot work +// for a command somebody copies out of a document on the web, so these carry +// the tag themselves - and a copy is a thing to keep honest, which is what the +// two guards below are for. // -// One file names the tags and everything else reads it, which works for the -// commands this project runs and cannot work for a command somebody types -// before they have the repository. So README.md carries the tag itself, and -// this is what keeps that copy honest. +// CONTRIBUTING.md joined on 2026-09-07 with the file itself. A guide telling +// somebody to build without the tag hands them a binary whose AVIF encoder +// reads past the end of a buffer, and the guide is the first thing a +// contributor follows. +var filesThatTellSomebodyHowToBuild = []string{"README.md", "CONTRIBUTING.md"} + +// The install instructions carry the tags the build needs. func TestTheInstallInstructionsCarryTheBuildTags(t *testing.T) { tags := buildTags() - raw, err := os.ReadFile(filepath.Join("..", "..", "README.md")) - if err != nil { - t.Fatalf("reading README.md: %v", err) + checked := 0 + for _, name := range filesThatTellSomebodyHowToBuild { + raw, err := os.ReadFile(filepath.Join("..", "..", name)) + if err != nil { + t.Fatalf("reading %s: %v", name, err) + } + checked += tagsOnEveryBuildLine(t, name, string(raw), tags) } + if checked == 0 { + t.Fatal("no line of those files was recognised as an install or build command, so this " + + "guard checked nothing") + } +} +// tagsOnEveryBuildLine checks one document and says how many commands it read. +func tagsOnEveryBuildLine(t *testing.T, name, body, tags string) int { + t.Helper() checked := 0 - for i, line := range strings.Split(string(raw), "\n") { + for i, line := range strings.Split(body, "\n") { if !strings.Contains(line, "go install") && !strings.Contains(line, "go build ") { continue } @@ -202,15 +223,12 @@ func TestTheInstallInstructionsCarryTheBuildTags(t *testing.T) { if strings.Contains(line, buildTagsFileName) || strings.Contains(line, "-tags "+tags) { continue } - t.Errorf("README.md line %d tells somebody to build without the build tags:\n %s\n"+ + t.Errorf("%s line %d tells somebody to build without the build tags:\n %s\n"+ "What to do: add -tags %q to it, or read them from %s where the command is run "+ "inside a checkout. Without them the AVIF encoder reads past the end of a buffer.", - i+1, strings.TrimSpace(line), tags, buildTagsFileName) - } - if checked == 0 { - t.Fatal("no line of README.md was recognised as an install or build command, so this " + - "guard checked nothing") + name, i+1, strings.TrimSpace(line), tags, buildTagsFileName) } + return checked } // The size that crashed, encoded here so a build that lost the tag says so diff --git a/internal/guard/community_test.go b/internal/guard/community_test.go index 2a5dc39..13966ca 100644 --- a/internal/guard/community_test.go +++ b/internal/guard/community_test.go @@ -50,7 +50,14 @@ func TestNothingThatDirectsAPersonPointsAtAnotherProject(t *testing.T) { if err != nil { t.Fatalf("walking .github: %v", err) } - files = append(files, filepath.Join(root, "SECURITY.md")) + // SECURITY.md and CONTRIBUTING.md sit beside .github rather than in it, and + // both point somebody somewhere. The contributing guide arrived on + // 2026-09-07 carrying four links to this repository, which is exactly the + // shape this guard exists for: these files get written by copying one from + // another project, and a link left pointing at the old one reads perfectly. + files = append(files, + filepath.Join(root, "SECURITY.md"), + filepath.Join(root, "CONTRIBUTING.md")) checked := 0 for _, p := range files {