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/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 { 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