diff --git a/.github/requirements-mutation.in b/.github/requirements-mutation.in new file mode 100644 index 00000000..bd2df9eb --- /dev/null +++ b/.github/requirements-mutation.in @@ -0,0 +1,20 @@ +# What the mutation runner needs beyond Python itself, pinned, in a file a bot +# can edit. +# +# The runner is internal/guard/mutation/mutate.py, and CI runs it from +# .github/workflows/mutation.yml. It uses psutil for one thing: watching the +# memory of the whole process tree an entry starts, so that a mutation which +# removes a limit cannot take the machine down with it. It is never linked into +# either binary and go.mod never sees it. +# +# Licence read from the pinned version rather than recalled, on 2026-10-15: +# BSD-3-Clause, stated by the wheel metadata and carried in full in the LICENSE +# file beside it. +# +# requirements-mutation.txt next to this file is compiled from it by +# pip-compile, the same way requirements-semgrep.txt is, so Dependabot proposes +# a set pip can install. To compile it by hand, from the root of the +# repository: +# +# docker run --rm -v "$PWD/.github:/w" -w /w python:3.14-slim sh -c "pip install -q pip==26.1.1 pip-tools==7.5.3 && pip-compile --strip-extras --no-emit-index-url --output-file=requirements-mutation.txt requirements-mutation.in" +psutil==7.2.2 diff --git a/.github/requirements-mutation.txt b/.github/requirements-mutation.txt new file mode 100644 index 00000000..e85e2047 --- /dev/null +++ b/.github/requirements-mutation.txt @@ -0,0 +1,8 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-index --output-file=requirements-mutation.txt --strip-extras requirements-mutation.in +# +psutil==7.2.2 + # via -r requirements-mutation.in diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 00000000..efe4759d --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,171 @@ +# Mutation testing: every guard broken on purpose, to see that it notices. +# +# A green suite says the code does what the guards check. It does not say the +# guards check anything - that sentence needs each one broken on purpose, and +# internal/guard/mutation/entries.py holds one entry per break. Until +# 2026-10-15 the entries lived outside the repository and ran on one machine, +# where a full pass took over an hour and was rarely run. Of 1779 entries that +# day, 236 had been proven against the code as it stood. +# +# Two kinds of run, one path through the same jobs: +# +# - weekly, and by hand from the Actions tab: every entry, cut into sixteen +# parts that run side by side. Red when a mutation is not caught. This is +# what catches a guard that stopped reaching its code through a change in +# a third file, which no selection by file can see. +# - on a pull request: only the entries the change reaches - the file an +# entry mutates, the file its guard is in, or the entry itself. Reported +# and never red for now, while the runner earns trust on these machines. +# +# The plan job decides how many entries and parts there are, each part judges +# its own share in disposable copies of the tree, and the report job puts the +# parts together. A part that never reports is a failure there rather than a +# smaller run. +name: mutation + +on: + pull_request: + schedule: + # Sunday night. Monday morning already holds the fuzzing job of ci.yml at + # 04:17, the OSV scan at 05:17 and the tool versions at 06:00, and this + # run takes sixteen of the twenty jobs a free plan runs at once. + - cron: "37 1 * * 0" + workflow_dispatch: + +permissions: + contents: read + +# A newer push to the same pull request makes the older run worthless, so it +# is cancelled rather than left to hold sixteen machines. +concurrency: + group: mutation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.27.2" + # Built as the test job builds on Linux, so a verdict here is about the + # guards CI actually runs. Entries on files only a cgo build or only + # Windows compiles come back UNJUDGED, said out loud in the report. + CGO_ENABLED: "0" + +jobs: + plan: + name: which entries + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + entries: ${{ steps.plan.outputs.entries }} + parts: ${{ steps.plan.outputs.parts }} + shards: ${{ steps.plan.outputs.shards }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The whole history, because a pull request is compared with its + # base and the base has to be here to be compared with. + fetch-depth: 0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: what the runner needs + run: pip install -r .github/requirements-mutation.txt + + - name: the plan + id: plan + env: + BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} + run: | + python internal/guard/mutation/mutate.py --plan ${BASE:+--changed "$BASE"} >> "$GITHUB_OUTPUT" + if grep -qx 'entries=0' "$GITHUB_OUTPUT"; then + echo "No mutation entry is reached by this change, so none was run." >> "$GITHUB_STEP_SUMMARY" + fi + + run: + name: part ${{ matrix.shard }} + needs: plan + if: needs.plan.outputs.entries != '0' + runs-on: ubuntu-latest + # A part of the weekly run is about 110 entries at three at a time. The + # first run measures what that costs here, and this ceiling is far above + # it so that a hang ends as a part that did not report. + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + shard: ${{ fromJSON(needs.plan.outputs.shards) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: false + cache: true + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: what the runner needs + run: pip install -r .github/requirements-mutation.txt + + - name: the entries of this part + # Never red on its own. The verdicts go to the JSON file and the + # report job decides - and a step that crashed before writing it is + # found there as a part that did not report. + continue-on-error: true + env: + BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} + SHARD: ${{ matrix.shard }} + PARTS: ${{ needs.plan.outputs.parts }} + run: > + python internal/guard/mutation/mutate.py ${BASE:+--changed "$BASE"} + --shard "$SHARD/$PARTS" --workers 3 --json "result-$SHARD.json" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: mutation-result-${{ matrix.shard }} + path: result-*.json + if-no-files-found: ignore + retention-days: 14 + + report: + name: what the run found + needs: [plan, run] + if: always() && needs.plan.result == 'success' && needs.plan.outputs.entries != '0' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + # A run in which no part reported has nothing to download, and that + # is for the report to say rather than for this step to fail on. + continue-on-error: true + with: + pattern: mutation-result-* + path: results + merge-multiple: true + + - name: the verdict + env: + ENTRIES: ${{ needs.plan.outputs.entries }} + PARTS: ${{ needs.plan.outputs.parts }} + # Red on the weekly run and on a run by hand. On a pull request the + # same findings are warnings until the runner has earned trust here. + GATE: ${{ github.event_name != 'pull_request' && '--gate' || '' }} + run: > + python internal/guard/mutation/report.py --dir results + --entries "$ENTRIES" --shards "$PARTS" $GATE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 306eba13..042121e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,7 +109,11 @@ whose test suites depend on it. 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". + is "which test would go red if this change were undone". Each test in + `internal/guard` is also broken on purpose by an entry in + `internal/guard/mutation/entries.py`, and the `mutation` check on a pull + request runs the entries the change reaches. A new test needs an entry there, + or a line in `internal/guard/mutationcoverage_test.go` saying why it has none. - **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. @@ -123,7 +127,9 @@ whose test suites depend on it. - 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. + linters, and checks the dependency and supply chain gates. The `mutation` + check reports a break no test noticed as a warning and does not block the + merge for now. - 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. diff --git a/internal/guard/mutation/entries.py b/internal/guard/mutation/entries.py new file mode 100644 index 00000000..cd343a95 --- /dev/null +++ b/internal/guard/mutation/entries.py @@ -0,0 +1,15075 @@ +"""The mutation entries. Each one breaks one behaviour on purpose and names +the guard that has to notice. + +A green suite is not evidence. The evidence is the sentence "this test would be +red if I broke that", and the only way to get it is to break it on purpose. + +An entry is five strings, in this order: + + - what breaks, in a sentence + - the file, relative to the root of the repository + - the exact text to find, which has to occur exactly once in that file + - the text that takes its place + - the name of the guard that has to go red + +mutate.py next to this file runs them. The guard +TestEveryGuardIsEitherProvenByMutationOrListedAsNotProven reads this file too, +and holds every guard to being named here, proven by a probe, or written down +as not proven. +""" +MUTATIONS = [ + # --- the contributing guide is held to the same gates as the rest of the + # repository text (2026-09-07) --- + ( + "the contributing guide tells somebody to build without the tag", + "CONTRIBUTING.md", + 'go build -tags "$(cat .github/build-tags)" ./cmd/tfg', + "go build ./cmd/tfg", + "TestTheInstallInstructionsCarryTheBuildTags", + ), + ( + "the contributing guide names a Go version this module does not admit", + "CONTRIBUTING.md", + "Needs Go 1.27.2 or newer", + "Needs Go 1.21 or newer", + "TestTheBuildFloorIsThePinnedToolchainAndTheDocumentsSaySo", + ), + ( + "the contributing guide sends somebody to another project's issue tracker", + "CONTRIBUTING.md", + "https://github.com/donislawdev/TestingFilesGenerator/issues/new/choose", + "https://github.com/donislawdev/QADataGenerator/issues/new/choose", + "TestNothingThatDirectsAPersonPointsAtAnotherProject", + ), + ( + "the contributing guide picks up a semicolon", + "CONTRIBUTING.md", + "and nothing else for the command line tool.", + "and nothing else for the command line tool;", + "TestProseInTheRepositoryUsesFlatHyphensAndNoSemicolons", + ), + # --- the window says what the command line says about the manifest ceiling + # (2026-09-07, O184) --- + ( + "the preview stops warning that the record will be too big to read back", + "internal/gui/window/run.go", + "r.say(append([]string{said}, manifestReachNote(res)...)...)", + "r.say(said)", + "TestTheWindowSaysWhenItsManifestWillBeTooBigToReadBack", + ), + ( + "a finished run stops warning, so somebody who never pressed Preview is told nothing", + "internal/gui/window/run.go", + "said := append([]string{outcome}, manifestReachNote(res)...)", + "said := []string{outcome}", + "TestAFinishedRunInTheWindowSaysItsManifestIsTooBigToReadBack", + ), + ( + "the window warns about every run, which teaches people to skip the line", + "internal/gui/window/runreport.go", + "if !over {", + "if false && !over {", + "TestAnOrdinaryPreviewSaysNothingAboutTheManifestCeiling", + ), + ( + "the document answers a different question than the plan, so one run is warned about on one surface only", + "internal/manifest/manifest.go", + "return TooLargeToReadBack(len(m.Files), m.notedFiles)", + "return TooLargeToReadBack(len(m.Files)+1000, m.notedFiles)", + "TestBothSurfacesJudgeTheSameRunTheSameWay", + ), + # --- verify says whose the other files are (2026-09-07, T8) --- + ( + "the neighbour's files go back to being called extra", + "internal/audit/audit.go", + "if by, ok := neighbours.claimedBy[p]; ok {", + "if by, ok := neighbours.claimedBy[p]; ok && false {", + "TestVerifyNamesTheFilesOfAnotherRunRatherThanCallingThemExtra", + ), + ( + "the neighbour's record itself goes back to being called extra", + "internal/audit/audit.go", + "if neighbours.records[p] {", + "if false && neighbours.records[p] {", + "TestTheJSONReportCarriesTheOtherRunsFilesAndStillSaysItMatched", + ), + ( + "every file nobody claims is handed to the neighbour, so a polluted directory reads as a shared one", + "internal/audit/audit.go", + 'return Extra, ""', + 'return AnotherRun, ""', + "TestAStrayFileBesideAnotherRunsRecordIsStillExtra", + ), + ( + "the neighbour's files count as disagreements again, so a shared directory is permanently red", + "internal/cli/verify.go", + "if d.Kind != audit.AnotherRun {\n\t\t\tn++", + "if d.Kind != audit.Extra {\n\t\t\tn++", + "TestADirectorySharedWithAnotherRunStillMatchesThisManifest", + ), + ( + "nothing counts as a disagreement, so a missing file is called a match", + "internal/cli/verify.go", + "return n", + "return 0", + "TestARealDifferenceIsStillAMismatchInASharedDirectory", + ), + ( + "the attribution stops naming which run, so the reader is told somebody else's and not whose", + "internal/audit/neighbours.go", + "base := filepath.Base(rel)", + 'base := "a run"', + "TestTheSentenceAboutAnotherRunNamesTheRecordThatClaimsTheFile", + ), + ( + "every unclaimed file is opened and parsed, which is the design that was measured out", + "internal/audit/neighbours.go", + 'return strings.EqualFold(filepath.Ext(rel), ".json")', + 'return true || strings.EqualFold(filepath.Ext(rel), ".json")', + "TestARecordUnderAnotherExtensionIsStillExtra", + ), + # --- one directory holds one run at a time (2026-09-07, T2) --- + ( + "the check goes, so a second run walks into a directory another run is filling", + "internal/engine/preflight.go", + "if path := RunLockPath(opt.OutDir); exists(path) {", + "if path := RunLockPath(opt.OutDir); false && exists(path) {", + # Named at the dry run rather than at the real one, and the first + # attempt got that wrong. The real run is protected twice - this check + # and the claim itself - so removing one leaves the other, and the + # guard for a refused run stayed green while the mutation was in. What + # only this check covers is the preview: the claim comes after the dry + # run has already returned, so without this a preview would promise a + # run into a directory somebody else is filling. + "TestADryRunNeitherTakesTheDirectoryNorIgnoresIt", + ), + ( + "the lock name carries the process id, so two runs never meet on it", + "internal/engine/preflight.go", + "return filepath.Join(outDir, core.RunLockName)", + 'return filepath.Join(outDir, fmt.Sprintf("%s%d", core.RunLockName, os.Getpid()))', + "TestTheRunLockIsSpelledInOnePlace", + ), + ( + "verify calls our own run lock a file somebody else put there", + "internal/audit/audit.go", + "core.IsRunLockName(base)", + "false && core.IsRunLockName(base)", + "TestVerifyNamesTheRunLockAsOursRatherThanAsSomebodyElses", + ), + ( + "the refusal stops naming the file to remove, so a stale lock is a dead end", + "internal/engine/errors.go", + "core.Shown(e.Dir), core.Shown(e.Path))", + 'core.Shown(e.Dir), "")', + "TestASecondRunIntoADirectoryARunIsHoldingIsRefusedBeforeItWritesAnything", + ), + # --- a preset renders a recipe that still means what it says (2026-09-06) --- + ( + "the whitelist on a spread value goes, so a carriage return reaches the document raw", + "internal/preset/sizeboundaries.go", + 'if bad := firstUnusable(piece); bad != "" {', + 'if bad := firstUnusable(piece); false && bad != "" {', + "TestEveryPresetRendersARecipeThatParsesBack", + ), + # --- two one word findings, and a third the guard for them found (2026-09-06) --- + ( + "the window loads uxtheme by name again, through the ordinary search order", + "internal/gui/darkmenus_windows.go", + "uxtheme, err := syscall.LoadDLL(path)", + "_ = path; uxtheme, err := syscall.LoadDLL(\"uxtheme.dll\")", + "TestALibraryIsOnlyLoadedLazilyWhenTheSystemAlreadyHasIt", + ), + ( + "the text catalogue is loaded a second time, so the localiser is written twice", + "internal/gui/run_cgo.go", + "if err := text.LoadBuiltIn(speak); err != nil {", + "if err := text.LoadBuiltIn(speak); err != nil || text.LoadBuiltIn(speak) != nil {", + "TestTheTextCatalogueIsLoadedFromOnePlaceOnly", + ), + # --- the documents say what the tool does (2026-09-06) --- + ( + "the security policy goes back to calling the binaries unsigned", + "SECURITY.md", + "**The Windows and macOS downloads are signed**", + "**The released binaries are not signed.** Signing is not set up.**", + "TestTheSecurityPolicySaysWhatTheReleaseDoesAboutSigning", + ), + ( + "the locking setting stops saying where the key comes from", + "internal/format/archive/archive.go", + "from the run seed, so the same recipe gives the same archive on any machine, and the password ", + "with the password you gave, and the password ", + "TestTheLockingSettingSaysItIsAFixtureRatherThanProtection", + ), + # --- a credential appears where it was meant to and nowhere else (2026-09-06) --- + ( + "the recorded command repeats the password again", + "internal/cli/cli.go", + 'return prefix + name + "=" + redactedValue', + "return arg", + "TestTheRecordedCommandDoesNotRepeatACredential", + ), + ( + "the archive password stops being declared a credential", + "internal/format/archive/archive.go", + "Secret: true,", + "Secret: false,", + "TestTheRecordAndTheRegistryAgreeOnWhatIsACredential", + ), + # --- the build tags reach every build, including the ones people type (2026-09-06) --- + ( + "an analysis job stops passing the build tags, so it reads a build we do not ship", + ".github/workflows/ci.yml", + 'go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -tags "$(cat .github/build-tags)" ./...', + "go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...", + "TestEveryWorkflowCommandThatBuildsUsPassesTheBuildTags", + ), + ( + "the refusal stops being part of any build, so an untagged build compiles again", + "internal/format/avif/requiretag.go", + "//go:build !noasm", + "//go:build ignore", + "TestABuildWithoutTheBuildTagsRefusesAndSaysWhy", + ), + ( + "the install instructions drop the tag again", + "README.md", + "go install -tags noasm github.com/donislawdev/TestingFilesGenerator/cmd/tfg@latest", + "go install github.com/donislawdev/TestingFilesGenerator/cmd/tfg@latest", + "TestTheInstallInstructionsCarryTheBuildTags", + ), + # --- a manifest too big to read back says so where a script reads (2026-09-06) --- + ( + "the report stops saying the manifest cannot be read back", + "internal/manifest/manifest.go", + "_, m.Summary.TooLargeToReadBack = TooLargeToReadBack(len(m.Files), m.notedFiles)", + "_, _ = TooLargeToReadBack(len(m.Files), m.notedFiles)", + "TestAManifestTooBigToReadBackSaysSoInTheReportAScriptReads", + ), + ( + "every manifest carries the ceiling field, changing the bytes of all of them", + "internal/manifest/manifest.go", + 'TooLargeToReadBack bool `json:"too_large_to_read_back,omitempty"`', + 'TooLargeToReadBack bool `json:"too_large_to_read_back"`', + "TestAnOrdinaryRunSaysNothingAboutTheCeilingInItsReport", + ), + # --- a stopped run says so in our own words (2026-09-06) --- + ( + "a cancelled run goes back to printing Go's own vocabulary", + "internal/cli/errors.go", + 'return "stopped before it finished."', + "return err.Error()", + "TestAStoppedRunSaysSoInOurOwnWords", + ), + ( + "a run that ran out of time reads the same as one somebody cancelled", + "internal/cli/errors.go", + 'return "stopped before it finished, because the time allowed for it ran out."', + "return err.Error()", + "TestARunThatRanOutOfTimeSaysThatRatherThanThatItWasCancelled", + ), + # --- a size refusal names the key the recipe carries (2026-09-06) --- + ( + "a range target is addressed to a size key it does not have", + "internal/engine/errors.go", + "if setting == core.SettingSize && t != nil && t.Range.Used {", + "if setting == core.SettingSize && t != nil && t.Range.Used && false {", + "TestASizeRefusalIsAddressedToTheKeyTheRecipeCarries", + ), + ( + "every size refusal is addressed to size-range, including ordinary targets", + "internal/engine/errors.go", + "if setting == core.SettingSize && t != nil && t.Range.Used {", + "if setting == core.SettingSize && t != nil {", + "TestASizeRefusalIsAddressedToTheKeyTheRecipeCarries", + ), + # --- notes are grouped by what they say, not one line per file (2026-09-06) --- + ( + "every file gets its own note line again, the way it did until 2026-09-06", + "internal/manifest/manifest.go", + '\tg := n.byDetail[detail]\n\tif g == nil {\n\t\tg = ¬eGroup{}\n\t\tn.byDetail[detail] = g\n\t\tn.order = append(n.order, detail)\n\t}', + '\tg := n.byDetail[detail+name]\n\tif g == nil {\n\t\tg = ¬eGroup{}\n\t\tn.byDetail[detail+name] = g\n\t\tn.order = append(n.order, detail)\n\t}', + "TestNotesAreGroupedByWhatTheySayRatherThanOneLinePerFile", + ), + ( + "a note about one file stops leading with that file's name", + "internal/manifest/manifest.go", + '\tif g.count == 1 {', + '\tif g.count == 1 && false {', + "TestANoteAboutOneFileStillNamesThatFileFirst", + ), + ( + "a grouped note stops saying how many files it is not naming", + "internal/manifest/manifest.go", + '\tif hidden := g.count - len(g.first); hidden > 0 {', + '\tif hidden := g.count - len(g.first); hidden > 0 && false {', + "TestNotesAreGroupedByWhatTheySayRatherThanOneLinePerFile", + ), + ( + "the unnamed remainder loses its plural", + "internal/manifest/manifest.go", + 'core.Count(hidden, "file", "files"))', + 'fmt.Sprintf("%d files", hidden))', + "TestTheUnnamedRemainderCarriesTheRightNoun", + ), + # --- both leftover markers are recognised, and each is spelled once (2026-09-06) --- + ( + "the reading side forgets the second marker, so a half written record is an extra again", + "internal/audit/audit.go", + # One line of the case rather than the whole of it: the list grew a + # third marker on 2026-09-07 and moved onto three lines. + "core.IsWritingName(base)", + "core.IsWritingName(base) && false", + "TestAHalfWrittenRecordIsNamedForWhatItIs", + ), + ( + "a half written record gets the sentence written for a half written generated file", + "internal/audit/audit.go", + "if core.IsWritingName(filepath.Base(d.Path)) {", + "if core.IsWritingName(filepath.Base(d.Path)) && false {", + "TestAHalfWrittenRecordIsNamedForWhatItIs", + ), + # --- the build floor is the compiler CI runs (2026-09-06) --- + ( + "the README goes back to naming a floor the module no longer admits", + "README.md", + "**From source.** Needs Go 1.27.2 or newer, and nothing else:", + "**From source.** Needs Go 1.26.5 or newer, and nothing else:", + "TestTheBuildFloorIsThePinnedToolchainAndTheDocumentsSaySo", + ), + ( + "CI builds with a compiler older than the floor go.mod admits", + ".github/workflows/ci.yml", + 'GO_VERSION: "1.27.2"', + 'GO_VERSION: "1.26.8"', + "TestTheBuildFloorIsThePinnedToolchainAndTheDocumentsSaySo", + ), + # --- the write path claims every name it writes under (2026-09-06) --- + ( + "the claim believes os.Stat, so a link pointing at nothing reads as a free name", + "internal/core/createnew.go", + "_, lookErr := os.Lstat(path)", + "_, lookErr := os.Stat(path)", + "TestCreateNewCreatesOnlyWhenTheNameIsFree", + ), + ( + "the claim stops being exclusive, so it writes through whatever holds the name", + "internal/core/createnew.go", + "os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm", + "os.O_WRONLY|os.O_CREATE, perm", + "TestCreateNewCreatesOnlyWhenTheNameIsFree", + ), + ( + "recipe fmt -w writes its temporary copy with a plain create again", + "internal/core/replace.go", + "f, err := CreateNew(path, mode)", + "f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)", + "TestAHeldTemporaryNameStopsTheWriteRatherThanGoingThroughIt", + ), + ( + "a writer creates its own file again, so the one claim stops being the only door", + "internal/engine/parallel.go", + "fh, err := core.CreateNew(tmp, 0o666)", + "_ = core.CreateNew; fh, err := os.Create(tmp)", + "TestEveryFileThisToolWritesIsCreatedThroughOneClaim", + ), + ( + "an Office package stops being read back by an independent library, so a slide that is not a slide passes", + "internal/oracle/strict.py", + ' if key == "pptx":', + ' if False and key == "pptx":', + "TestTheStructuralCheckerRefusesAPackageThatIsNotADocument", + ), + ( + "the structural checker stops asking whether an entry carries its sizes after the data", + "internal/oracle/strict.py", + " if info.flag_bits & 0x08:", + " if False and info.flag_bits & 0x08:", + "TestTheStructuralCheckerRefusesAPackageThatIsNotADocument", + ), + ( + "the structural checker stops holding the archive comment to the size a reader accepts", + "internal/oracle/strict.py", + " if len(z.comment) > OPC_COMMENT_LIMIT:", + " if False and len(z.comment) > OPC_COMMENT_LIMIT:", + "TestTheStructuralCheckerRefusesAPackageThatIsNotADocument", + ), + ( + "an Office package goes back to writing its sizes after the data instead of in the header", + "internal/format/opc/opc.go", + "entry, err := zw.CreateRaw(head)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif withPadding {", + "entry, err := zw.CreateHeader(head)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif withPadding {", + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + "a part of an Office package stops declaring its own checksum, so a reader refuses the whole file", + "internal/format/opc/opc.go", + "\tentry, err := zw.CreateRaw(head)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = entry.Write(payload)", + "\tentry, err := zw.CreateHeader(head)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = entry.Write(payload)", + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + "the filler text of an Office document stops being a fixed width, so the floor moves with the seed", + "internal/format/opc/opc.go", + "\treturn Fixed(b.String(), LineWidth)", + "\treturn b.String()", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "the part carrying the label is compressed again, so its length stops being its bytes", + "internal/format/docx/docx.go", + "Body: document(paragraphs, seed, label), Store: true},", + "Body: document(paragraphs, seed, label)},", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "the padding part loses its content type, which the packaging specification requires", + "internal/format/opc/opc.go", + "\tb.WriteString(``)", + "", + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + "the smallest Office file is worked out for one label state instead of both", + "internal/format/xlsx/xlsx.go", + 'for _, label := range []string{"", core.Label("xlsx", 0, 0)} {', + 'for _, label := range []string{""} {', + "TestSizeBelowTheMinimumIsRefused", + ), + ( + "a BMP states a file size that is not the size of the file", + "internal/format/bmp/bmp.go", + 'binary.LittleEndian.PutUint32(head[2:6], uint32(total))', + 'binary.LittleEndian.PutUint32(head[2:6], uint32(total-1))', + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + "a BMP says its pixels start past where they really do", + "internal/format/bmp/bmp.go", + 'binary.LittleEndian.PutUint32(head[10:14], uint32(headers+m.gap))', + 'binary.LittleEndian.PutUint32(head[10:14], uint32(headers+m.gap+1))', + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + "a BMP grows its picture one row past what the request pays for", + "internal/format/bmp/bmp.go", + 'h := avail / int64(stride(width))', + 'h := avail/int64(stride(width)) + 1', + "TestEveryFormatHitsTheOrderedSizeExactly", + ), + ( + "a GIF cuts its comment into too few sub blocks, so one of them overflows its length byte", + "internal/format/gif/gif.go", + 'blocks = (delta - emptyComment + subBlockCost - 1) / subBlockCost', + 'blocks = (delta - emptyComment) / subBlockCost', + "TestEveryFormatHitsTheOrderedSizeExactly", + ), + ( + "a GIF accepts a size no arrangement of comments can produce", + "internal/format/gif/gif.go", + 'case delta < smallestCarryingComment:', + 'case delta < smallestCarryingComment && false:', + "TestEveryFormatHitsTheOrderedSizeExactly", + ), + ( + "a JPEG pads with an application segment instead of a comment, which every reader still accepts", + "internal/format/jpg/jpg.go", + 'head := [4]byte{0xFF, 0xFE, 0, 0}', + 'head := [4]byte{0xFF, 0xEF, 0, 0}', + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "a JPEG spreads its padding over the segments unevenly, so the file misses the size by a few bytes", + "internal/format/jpg/jpg.go", + 'if int64(i) < extra {', + 'if int64(i) <= extra {', + "TestEveryFormatHitsTheOrderedSizeExactly", + ), + ( + "a JPEG takes its floor from one seed instead of the worst of them, so the minimum it prints walks", + "internal/format/jpg/jpg.go", + 'for off := uint64(0); off < 256; off++ {', + 'for off := uint64(0); off < 1; off++ {', + "TestTheMinimumThisToolPrintsStandsWhateverTheSeedIs", + ), + ( + "an icon embeds a bitmap whose header forgets that the height counts the mask too", + "internal/format/ico/ico.go", + 'binary.LittleEndian.PutUint32(out[8:12], uint32(int32(h*2)))', + 'binary.LittleEndian.PutUint32(out[8:12], uint32(int32(h)))', + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + "an icon points at its picture without counting the padding in front of it", + "internal/format/ico/ico.go", + 'binary.LittleEndian.PutUint32(e[12:16], uint32(int64(preamble)+m.gap))', + 'binary.LittleEndian.PutUint32(e[12:16], uint32(preamble))', + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + "an icon understates how long its picture is", + "internal/format/ico/ico.go", + 'binary.LittleEndian.PutUint32(e[8:12], uint32(len(m.image)))', + 'binary.LittleEndian.PutUint32(e[8:12], uint32(len(m.image)-1))', + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + "the generate screen registers the opening format's settings before the screen is built, so they end up registered twice", + "internal/gui/window/generate.go", + # Widened on 2026-09-09. The screen gained a second guard on the same + # flag when the damage menu arrived, so the bare condition matched + # twice and staleness.py called it ambiguous - a mutation that could + # land in either of two places proves neither. The line under it is + # what makes this the format one. + 'if !g.ready {\n\t\treturn\n\t}\n\tg.propBox.RemoveAll()', + 'if !g.ready && false {\n\t\treturn\n\t}\n\tg.propBox.RemoveAll()', + "TestEveryRegisteredFieldCanBeMarkedAndUnmarked", + ), + ( + "the engine gains a capability nobody classified for the window", + "internal/format/imagedim/imagedim.go", + '\tSettingHeight = "height"', + '\tSettingHeight = "depth"', + "TestEveryEngineCapabilityIsClassifiedForBothSurfaces", + ), + ( + "a recipe key stops being counted as something the window has to reach", + "internal/recipe/keys.go", + 'if tag == "" || tag == "-" {', + 'if tag == "" || tag == "-" || tag == "locale" {', + "TestEveryEngineCapabilityIsClassifiedForBothSurfaces", + ), + ( + # The control that the real entry point still works on the machine that + # built the binary. Aimed at the INTERPRETER lookup rather than at + # either of the two paths to the script, and that choice is the whole + # point: both paths resolve here, so breaking one leaves the other + # answering and the guard stays green while proving nothing. + "the structural checker looks for an interpreter nobody installed", + "internal/oracle/oracle.go", + 'python, ok := inPath("python")()', + 'python, ok := inPath("python-not-here")()', + "TestTheStructuralCheckerIsAvailableWhereItWasBuilt", + ), + ( + # The second way in to the checker script, which is the only one a + # binary built somewhere else can use. Aimed at the name, so the walk + # runs to the top of the disk and finds nothing - which is what the + # container did for months while every structural check reported a skip. + "the walk for the structural checker looks for a name that is not there", + "internal/oracle/oracle.go", + 'filepath.Join(dir, "internal", "oracle", "strict.py")', + 'filepath.Join(dir, "internal", "oracle", "strict.pyc")', + "TestTheStructuralCheckerIsFoundByABinaryBuiltSomewhereElse", + ), + ( + # The same guard from the other side: the walk stops after the directory + # it starts in, so it never climbs to where the script actually is. This + # is the state the code was in before the walk existed at all. + "the walk for the structural checker stops where it starts", + "internal/oracle/oracle.go", + "if up == dir {", + "if up == dir || true {", + "TestTheStructuralCheckerIsFoundByABinaryBuiltSomewhereElse", + ), + ( + "the version in the file properties drifts from the one the program reports", + "internal/version/version.go", + 'const Version = "0.4.0"', + 'const Version = "0.1.0"', + "TestBothBinariesCarryTheirVersionAndAuthor", + ), + ( + "the undocumented ordinal for dark menus stops resolving", + "internal/gui/darkmenus_windows.go", + "uxthemeSetPreferredAppMode = 135", + "uxthemeSetPreferredAppMode = 1350", + "TestTheProgramAsksWindowsForDarkMenus", + ), + ( + "the Donate button leads nowhere", + "internal/gui/window/open.go", + "h.OpenLink(text.SupportURL)", + 'h.OpenLink("")', + "TestTheDonateButtonOpensTheSupportPage", + ), + ( + "the window and the sponsor file stop agreeing where the money goes", + "internal/gui/text/screens.go", + 'const SupportURL = "https://donislawdev.com/support/"', + 'const SupportURL = "https://donislawdev.com/donate/"', + "TestTheSponsorFileAndTheWindowAgreeWhereTheMoneyGoes", + ), + ( + "a screen leaves the window and the list the guards walk does not notice", + "internal/gui/window/open.go", + "&parts.Tab{ID: tabRecipe, Text: text.TabRecipe(), Content: rec.Object()},", + '&parts.Tab{ID: tabRecipe, Text: text.TabRecipe() + "!", Content: rec.Object()},', + "TestEveryTabInTheWindowIsOnTheListGuardsWalk", + ), + ( + "every batch registers its boxes under the same address, so batch 2 marks batch 1", + "internal/gui/window/recipe.go", + "return recipe.TargetAddress(index+1, setting)", + "return recipe.TargetAddress(1, setting)", + "TestEveryRefusalAboutABatchMarksTheBoxOfThatBatch", + ), + ( + "removing a batch removes nothing, so the ones below it never renumber", + "internal/gui/window/recipe.go", + "r.batches = append(r.batches[:index], r.batches[index+1:]...)", + "r.batches = append(r.batches[:index], r.batches[index:]...)", + "TestAddingAndRemovingBatchesKeepsWhatWasTypedAndRenumbersTheRest", + ), + ( + "the last batch can be removed, leaving a form that can produce nothing", + "internal/gui/window/recipe.go", + "if len(r.batches) <= 1 && !r.base.carriesTheRun() {", + "if len(r.batches) <= 1 && false {", + "TestTheLastBatchCannotBeRemoved", + ), + ( + "the recipe screen stops offering a box for the class of a batch", + "internal/gui/window/recipefolds.go", + "add(recipe.KeyGroup, text.FieldGroup(), text.HintGroup(),", + "add(recipe.KeyGroup+\"-gone\", text.FieldGroup(), text.HintGroup(),", + "TestTheRecipeScreenDrawsABoxForEverySettingItOffers", + ), + ( + "a run from the recipe screen ignores the manifest name it was given", + "internal/gui/window/recipe.go", + "ManifestName: manifestName(rec.Output.Manifest),", + "ManifestName: engine.DefaultManifestName,", + "TestWhatIsTypedOnTheRecipeScreenIsWhatGetsWritten", + ), + ( + "composing stops refusing characters a document cannot carry", + "internal/recipe/compose.go", + "if err := refuseUnwritable(d); err != nil {", + "if err := refuseUnwritable(d); err != nil && false {", + "TestEveryCharacterIsEitherCarriedThroughAComposedRecipeOrRefused", + ), + ( + "the unwritable class loses DEL, which is as invisible as the rest", + "internal/recipe/compose.go", + "if r < 0x20 || r == 0x7F {", + "if r < 0x20 {", + "TestEveryCharacterIsEitherCarriedThroughAComposedRecipeOrRefused", + ), + ( + "a composed target quietly loses its group", + "internal/recipe/compose.go", + 'add("group", t.Group)', + 'add("group", "")', + "TestARecipeComposedFromTypedTextSurvivesAnythingTypedIntoIt", + ), + ( + "a composed target never carries an expectation, so a bad one is never refused", + "internal/recipe/compose.go", + "if e := expectationEntry(t); e != nil {", + "if e := expectationEntry(t); e != nil && false {", + "TestAnEmptyOrHalfFilledScreenComposesIntoARefusalThatNamesSomething", + ), + ( + "a refusal stops naming the setting and points at the target as a whole", + "internal/recipe/errors.go", + 'return s.key + "." + setting', + "return s.key", + "TestEveryRecipeRefusalSaysWhichSettingItIsAbout", + ), + ( + "a refusal carries no address at all, as none of them did before 2026-08-18", + "internal/recipe/errors.go", + "Problem{What: what, Why: why, Fix: fix, At: at}", + 'Problem{What: what, Why: why, Fix: fix, At: ""}', + "TestEveryRecipeRefusalSaysWhichSettingItIsAbout", + ), + ( + "the address of a target becomes its id, so the box for a missing id has none", + "internal/recipe/errors.go", + 's.says = fmt.Sprintf("target %q", id)', + 's.says = fmt.Sprintf("target %q", id); s.key = id', + "TestEveryRecipeRefusalSaysWhichSettingItIsAbout", + ), + ( + "a refused recipe stops opening into its problems, so all of it lands in one spot", + "internal/recipe/errors.go", + "func (e *ValidationError) Unwrap() []error {", + "func (e *ValidationError) unwrapWithdrawn() []error {", + "TestARefusedRecipeArrivesAsItsProblemsEachKnowingItsField", + ), + ( + "a recipe problem stops saying which setting it is about", + "internal/recipe/errors.go", + "func (p Problem) AboutSetting() string { return p.At }", + 'func (p Problem) AboutSetting() string { return "" }', + "TestARefusedRecipeArrivesAsItsProblemsEachKnowingItsField", + ), + ( + "the machine readable report drops the address on the way out", + "internal/cli/recipecmd.go", + "Fix: p.Fix, At: p.At})", + "Fix: p.Fix})", + "TestTheMachineReadableReportCarriesTheAddressOfEachRefusal", + ), + ( + "the machine readable list goes back to carrying names alone", + "internal/cli/formats.go", + "Unit: p.Unit, Choices: p.Choices, Default: p.Default, Detail: p.Detail,", + "Unit: \"\", Choices: nil, Default: \"\", Detail: \"\",", + "TestTheMachineReadableListCarriesTheWholeDeclaration", + ), + ( + "a property value is no longer checked against its declaration", + "internal/format/format.go", + "if why := p.Allows(raw); why != \"\" {", + "if why := p.Allows(raw); why != \"\" && false {", + "TestABadPropertyValueIsTheCallersMistakeNotOurs", + ), + ( + "a value the format cannot take is reported as a bug in the tool", + "internal/cli/errors.go", + "var badValue *format.PropertyValueError", + "var badValue *format.NotAContainerError", + "TestABadPropertyValueIsTheCallersMistakeNotOurs", + ), + ( + "tfg formats ignores the format it was asked about, as it did until 2026-08-03", + "internal/cli/formats.go", + "if wanted != \"\" {", + "if wanted != \"\" && false {", + "TestFormatsAnswersAboutOneFormat", + ), + ( + "the declared range stops bounding the value", + "internal/format/format.go", + "if p.Min != 0 || p.Max != 0 {\n\t\t\tif n < p.Min || n > p.Max {", + "if p.Min != 0 || p.Max != 0 {\n\t\t\tif (n < p.Min || n > p.Max) && false {", + "TestTheRefusalComesFromTheDeclaration", + ), + ( + "a size starts counting in thousands like a boundary", + "internal/core/size.go", + 'case "m", "mb", "mi", "mib":', + 'case "mi", "mib":', + "TestASizeKeepsCountingIn1024s", + ), + ( + "every group is named by boundary role, not just a boundary set", + "internal/engine/engine.go", + "case t.BoundaryLimit > 0 && index < len(boundaryRoles):", + "case index < len(boundaryRoles):", + "TestAnOrdinaryGroupIsStillNumbered", + ), + ( + "a boundary set goes back to plain numbering", + "internal/engine/engine.go", + "case t.BoundaryLimit > 0 && index < len(boundaryRoles):", + "case t.BoundaryLimit > 0 && index < len(boundaryRoles) && false:", + "TestABoundarySetSaysWhichFileIsWhich", + ), + ( + "the boundary roles are put on in the wrong order", + "internal/engine/engine.go", + 'var boundaryRoles = [3]string{"under_1b", "at_limit", "over_1b"}', + 'var boundaryRoles = [3]string{"at_limit", "under_1b", "over_1b"}', + "TestABoundarySetSaysWhichFileIsWhich", + ), + ( + "a name template stops winning over the boundary roles", + "internal/engine/engine.go", + " case tmpl != \"\":", + " case tmpl != \"\" && false:", + "TestABoundarySetStillObeysANameTemplate", + ), + ( + "progress is reported only between files, so a big file stays silent", + "internal/engine/parallel.go", + "if c.report != nil {", + "if c.report != nil && false {", + "TestProgressArrivesWhileOneFileIsStillBeingWritten", + ), + ( + "the running total restarts at every file", + "internal/engine/parallel.go", + "\tg.bytesDone += bytes\n\tg.workDone += work\n\tg.say()\n}\n\n// finished", + "\tg.bytesDone = bytes\n\tg.workDone += work\n\tg.say()\n}\n\n// finished", + "TestProgressCountsTheWholeRunNotOneFile", + ), + ( + "the bar is drawn even when nothing is watching", + "internal/cli/progress.go", + "if err != nil || info.Mode()&os.ModeCharDevice == 0 {", + "if err != nil || info.Mode()&os.ModeCharDevice == 0 && false {", + "TestProgressStaysOffWhenNothingIsWatching", + ), + ( + "the run protects a manifest name other than the one it writes", + # Aimed at manifestNameOf rather than at either check, and that is the + # whole point. Two layers protect this name - the collision check in + # preflight and the claim that follows it - and each covers the other, + # so switching off either one on its own leaves the guard green and + # proves nothing. Both ask this function what the name is, so it is the + # one place a single mutation can reach both. Measured 2026-08-04: + # disabling the collision check alone was NOT CAUGHT. + "internal/engine/preflight.go", + " return opt.ManifestName\n}", + ' return opt.ManifestName + ".other"\n}', + "TestASecondRunNeverWritesOverAnEarlierManifest", + ), + ( + "a dry run skips the checks and promises a run that would be refused", + "internal/engine/engine.go", + "if err := preflight(ctx, files, opt); err != nil {", + "if err := preflight(ctx, files, opt); err != nil && !opt.DryRun {", + "TestADryRunReachesTheSameVerdictAsTheRealRun", + ), + ( + "a run bigger than the disk is allowed to start", + "internal/engine/preflight.go", + "if available < needed {", + "if available < needed && false {", + "TestADryRunSeesAFullDiskAndStillWritesNothing", + ), + ( + "asking for help is not recognised, so it falls through as a mistake", + "internal/cli/cli.go", + '\t\tcase "-h", "-help", "--help":\n\t\t\treturn true', + '\t\tcase "-h", "-help", "--help":\n\t\t\treturn false', + "TestAskingForHelpIsNotAMistake", + ), + ( + "the top level answers a request for help on the complaint channel", + "internal/cli/commands.go", + # io.Discard rather than errOut, which was the first version and came + # back BROKEN: the help entry names its error writer "_", so errOut is + # not a thing there and the mutant did not compile. Sending the answer + # nowhere breaks the same promise - it is not on stdout - and builds. + "\t\t\t\tusage(out)\n\t\t\t\treturn ExitOK", + "\t\t\t\tusage(io.Discard)\n\t\t\t\treturn ExitOK", + "TestTheTopLevelTellsAskingApartFromTyping", + ), + ( + "a mistyped command is called an option, as it was in the source project", + "internal/cli/cli.go", + 'unknown command %q', + 'unknown option %q', + "TestAMistypedCommandIsStillAMistake", + ), + ( + "a flag nobody wrote is treated as written", + "internal/cli/generate.go", + 'if given["seed"] {', + "if true {", + "TestAFlagNobodyWroteDoesNotOverrideTheRecipe", + ), + ( + "a flag that was written is ignored", + "internal/cli/generate.go", + 'if given["seed"] {', + "if false {", + "TestAFlagThatWasWrittenWinsAndIsRecorded", + ), + ( + "the formatter drops comments", + "internal/recipe/canonical.go", + "parser.ParseBytes(src, parser.ParseComments)", + "parser.ParseBytes(src, 0)", + "TestFormattingARecipeSettlesAndKeepsWhatAPersonWrote", + ), + ( + "the declared expectation is dropped on the way to the manifest", + # Aimed at a line with no column alignment in it. A pattern inside an + # aligned struct literal goes stale the moment gofmt moves a column, + # and a stale pattern is reported as SKIP rather than passing quietly. + "internal/engine/engine.go", + "switch f.Target.Expected {", + 'switch "" {', + "TestAnExpectationFromTheRecipeReachesTheManifestUnchanged", + ), + ( + "validation stops at the first problem", + "internal/recipe/errors.go", + "func (p *problems) add(at, what, why, fix string) {", + "func (p *problems) add(at, what, why, fix string) {\n\tif len(p.list) > 0 {\n\t\treturn\n\t}", + "TestAllTheProblemsInARecipeAreReportedAtOnce", + ), + ( + "an invalid recipe is generated from anyway", + "internal/recipe/recipe.go", + "if err := p.err(); err != nil {\n\t\treturn nil, err\n\t}", + "if err := p.err(); err != nil {\n\t\t_ = err\n\t}", + "TestARecipeThatFailsValidationWritesNothing", + ), + ( + "an unknown key is accepted in silence", + "internal/recipe/recipe.go", + "yaml.NodeToValue(body, raw, yaml.Strict())", + "yaml.NodeToValue(body, raw)", + "TestATypoInARecipeKeyIsRefused", + ), + ( + "a file holding two documents is run anyway", + "internal/recipe/canonical.go", + "if n := recipesIn(f); n > 1 {", + "if n := recipesIn(f); n > 99 {", + "TestAFileHoldingTwoRecipesIsRefusedRatherThanHalfRun", + ), + ( + # The regression this replaced: signal.NotifyContext does not say which + # signal arrived, so every stop came out as Ctrl+C and the 143 in the + # frozen table was unreachable. CI could not tell a timeout from a + # person cancelling. + # Untouchable rule 4. Padding octets to three digits made the line + # length trivial to predict and produced addresses no real log holds - + # and a leading zero is read as octal by some parsers. Caught by the + # structural checker written to the format, not to our output. + "log addresses go back to padded octets", + "internal/format/logfile/address.go", + "dst = strconv.AppendInt(dst, int64(a.parts[i]), 10)", + "dst = strconv.AppendInt(append(dst, '0'), int64(a.parts[i]), 10)", + "TestEveryLineOfALogIsAWholeEntry", + ), + ( + # A preview and a real run have to be distinguishable in the report, + # or a script cannot tell whether anything was deleted. + "the cleanup report stops saying whether it applied anything", + "internal/cli/cleanup.go", + "report := cleanupReport{Manifest: run.path, Directory: run.dir, Applied: true}", + "report := cleanupReport{Manifest: run.path, Directory: run.dir, Applied: false}", + "TestCleanupAndValidateReportAsJSON", + ), + ( + # Collapsing the problems into one string is exactly what the JSON + # form exists to avoid - RC7 reports them all at once and a script + # should not have to split a sentence back apart. + "validate reports its problems as one blob again", + "internal/cli/recipecmd.go", + "for _, p := range invalid.Problems {", + "for _, p := range invalid.Problems[:1] {", + "TestCleanupAndValidateReportAsJSON", + ), + ( + "a stop by signal is reported as Ctrl+C again", + "internal/cli/cli.go", + "if s == syscall.SIGTERM {", + "if s == syscall.SIGTERM && false {", + "TestAStopBySignalIsToldApartFromCtrlC", + ), + ( + "two endings collapse onto one exit code", + "internal/cli/cli.go", + "ExitTerminated = 143", + "ExitTerminated = 130", + "TestTheExitCodeConstantsMatchTheFrozenTable", + ), + ( + # The whole point of MD over TXT is the structure. A document that + # stopped emitting blocks would still be the right size and would + # still be valid Markdown, so size and determinism see nothing. + "markdown stops emitting structure and becomes plain prose", + "internal/format/md/md.go", + "buf = appendBlock(buf, rng)", + "buf = buf[:mark]", + "TestAMarkdownDocumentCarriesRealStructure", + ), + ( + # A log is read line by line. Cutting the last line to land the byte + # count leaves a half entry - which looks like a real log mid rotation + # and is exactly why it has to be caught rather than eyeballed. + # The padded request path is where a line can stop being a whole entry + # now. A newline inside it splits the closing entry into several lines, + # most of which are not entries, and the total byte count does not move + # at all. Measured 2026-08-01: the size and determinism guards both + # stayed green while this was in. + # + # This replaced an earlier entry that cut the closing line short. That + # one went stale when the record loop moved into core.FillRecords, + # which refuses a record of the wrong length before it reaches the file + # - so the defect stopped being a wrong file and became an error. + "a newline inside the padded request path splits the closing log entry", + "internal/format/logfile/shapes.go", + 'dst = append(dst, " /"...)', + 'dst = append(dst, " /\\n"...)', + "TestEveryLineOfALogIsAWholeEntry", + ), + ( + # The address is no longer fixed width - it is written the way a real + # log writes it - so the line length depends on which address came up. + # Getting that arithmetic wrong misses the byte count on the closing + # entry, which is what this catches. + "the varying width of the address stops being counted", + "internal/format/logfile/shapes.go", + "int64(addr.length()) + tail.width(len(agent))", + "int64(12) + tail.width(len(agent))", + "TestEveryFormatHitsTheOrderedSizeExactly", + ), + ( + # SVG is case sensitive, so a lowercase viewbox is simply not the + # attribute - and the substitution is the SAME LENGTH, so the file does + # not move by one byte. Measured: size and determinism both stayed + # green, and the drawing still opens. + # + # The pattern moved on 2026-09-08, when the root element stopped being + # a constant with 800 and 600 written into it and became a function of + # the two dimensions. Same mutation, same guard, new place to make it. + "the svg viewBox loses its capital letter and stops being an attribute", + "internal/format/svgfile/svg.go", + 'viewBox="0 0 %d %d"', + 'viewbox="0 0 %d %d"', + "TestAnSVGDrawingCarriesRealShapes", + ), + ( + # A list left open runs to the end of the document, where it collides + # with the tag that closes the body. HTML readers recover from this by + # design, which is exactly why it needs a guard written to the format + # rather than a guard that asks whether a parser complained. + "an html list is never closed, so the document stops being balanced", + "internal/format/htmlfile/blocks.go", + 'return append(dst, m[ulClose]...)', + 'return dst', + "TestAnHTMLDocumentIsBalancedAndCarriesBlocks", + ), + ( + # A bare ampersand in text. The tolerant reader swallows it without a + # word and the size stays exactly right. + "the html ampersand stops being escaped", + "internal/format/htmlfile/blocks.go", + 'dst = append(dst, " & "...)', + 'dst = append(dst, " & "...)', + "TestAnHTMLDocumentIsBalancedAndCarriesBlocks", + ), + ( + # A run that produced nothing still writes a manifest, and somebody's + # script reads it. Left uninitialised the file list renders as null + # while every empty collection beside it renders as {}, so a reader + # looping over the entries meets a value that is not a list. Found by + # the Ctrl+C probe on 2026-08-01, which crashed on exactly that. + "the manifest file list renders as null instead of an empty list", + "internal/manifest/manifest.go", + "Files: []File{},", + "Files: nil,", + "TestAManifestWithNoFilesRendersAnEmptyListNotNull", + ), + ( + # Untouchable rule 2: seeds are derived, never drawn from a shared + # stream. This is the shape that rule exists to forbid - the file seed + # starts depending on how many files the run has already planned, so + # adding one target at the top shifts the bytes of everything below it + # and every hash in somebody's CI goes red at once. + # + # The regression surface claimed this was proven by mutation and it was + # not: no entry here named a locality test. Found 2026-08-01 by the + # coverage guard, not by reading. + "the file seed comes from a stream shared by the whole run", + "internal/engine/plantarget.go", + "core.FileSeed(targetSeed, idx)", + "core.FileSeed(targetSeed, idx+len(pl.out))", + # Reordering only. Checked one test at a time: the same substitution + # leaves the other three locality guards green, because a target + # appended at the end shifts nothing before it. Naming a test that + # stays green would have reported this mutation as uncaught. + "TestReorderingTargetsChangesNothing", + ), + ( + # Adding a target anywhere changes every other target, because the seed + # starts depending on how many targets the run has. + "the number of targets reaches the seed of each of them", + "internal/engine/engine.go", + # totalFiles was tried here first and proved nothing: it is added to + # after this line, so the targets already planned keep the seed they + # had and a target appended at the end changes nothing before it. The + # count of targets is the value that reaches every one of them. + "targetSeed := core.TargetSeed(opt.Seed, t.ID)", + "targetSeed := core.TargetSeed(opt.Seed+int64(len(targets)), t.ID)", + "TestAddingATargetLeavesTheOthersUntouched", + ), + ( + # Raising a count rewrites the files that were already there, which is + # the failure somebody meets after generating a fixture set twice. + "the file seed depends on how many files the target holds", + "internal/engine/plantarget.go", + "core.FileSeed(targetSeed, idx)", + "core.FileSeed(targetSeed, idx+len(t.Sizes))", + "TestRaisingTheCountLeavesTheEarlierFilesIdentical", + ), + ( + # The sentence an operating system hands back is opaque whatever + # language it is in, and Go falls back to the language of the machine + # when the English resource is missing. Ours replaces it and carries the + # number, which is the part that means the same thing everywhere. + "the system's own sentence reaches the terminal instead of ours", + "internal/cli/errors.go", + 'osText != "" && strings.Contains(full, osText)', + 'osText != "" && strings.Contains(full, osText) && false', + "TestASystemErrorReachesTheUserInOurWords", + ), + ( + # Pointing a command at a directory is the mistake somebody makes, and + # left alone it surfaces as "Incorrect function." from Windows. + "a command pointed at a directory falls through to the system error", + "internal/cli/errors.go", + "if err != nil || !info.IsDir() {", + "if err != nil || !info.IsDir() || true {", + "TestPointingACommandAtADirectorySaysSo", + ), + ( + # The other half of untouchable rule 2: the identity of a target is what + # its seed is derived from. Drop it and two targets produce the same + # files, so renaming one changes nothing when it has to change + # everything of that target. + "the target id stops reaching the seed, so every target draws the same", + "internal/engine/engine.go", + "targetSeed := core.TargetSeed(opt.Seed, t.ID)", + "targetSeed := core.TargetSeed(opt.Seed, t.ID[:0])", + "TestChangingATargetIdChangesThatTargetOnly", + ), + ( + # The shared rule for every record based format: whole records, then a + # closing record built to the byte. Break the loop and the padding value + # swallows the whole file - one row with a hundred kilobyte field. The + # size stays exact, the run stays repeatable, and the file still parses. + # Measured 2026-08-01: this is what topples the Python csv reader at its + # default settings, which is the reader a tester has nearest. + "the padding swallows the file instead of going through whole records", + "internal/core/records.go", + "if remaining-int64(len(buf)-mark) < shortest {", + "if remaining-int64(len(buf)-mark) < shortest || true {", + "TestEveryRowOfACSVHasTheSameColumns", + ), + ( + # The description column is quoted because it carries the separator. + # Stop asking and it never gets quotes, so the separators inside it + # become column separators - a ragged row, which is a defect this tool + # offers deliberately elsewhere. Size and determinism both stay green, + # measured. Since 2026-09-03 the question is asked in one place, so + # this is where the answer is broken. + "the csv description stops being quoted, so its separators split the row", + "internal/format/csvfile/dialect.go", + "return bytes.IndexByte(description, sep) >= 0", + "return bytes.IndexByte(description, sep) >= 0 && false", + "TestEveryRowOfACSVHasTheSameColumns", + ), + ( + # The written out rule. This is the sentence that actually went stale: + # it never says the word "formats", so a rule asking for a number next + # to that word would not have seen it. Measured while writing the + # guard - the first version missed exactly this one. + "the site spells out a format count instead of counting it", + "web/content/en/site.json", + "All {{ .Facts.FormatCount }} open in the software that owns them.", + "All twenty open in the software that owns them.", + "TestTheSiteNeverCountsTheFormatsInItsOwnWords", + ), + ( + # The digit rule, which is the shape of the older defect: a page title + # that read "PDF, DOCX, PNG, ZIP and 16 more formats". A digit has to + # stand next to the word, or "--size 2mb" would redden it. + "the site types a format count as a number instead of counting it", + "web/content/en/site.json", + "It produces real files of {{ .Facts.FormatCount }} formats", + "It produces real files of 26 formats", + "TestTheSiteNeverCountsTheFormatsInItsOwnWords", + ), + ( + # YAML forbids a tab in indentation outright. Three spaces and a tab is + # the same four bytes, so the size guard, the determinism guard and the + # pinned hashes would all have to be read as saying nothing - which is + # the whole shape this entry exists to catch. Verified by hand before + # it was written down: the oracle and the structural check both redden. + "yaml indents with a tab, which is the same four bytes and not YAML", + "internal/format/yamlfile/yaml.go", + 'openName = "\\n name: "', + 'openName = "\\n \\tname: "', + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + # The record a builder throws away takes its number with it, and without + # the decrement the ids skip. Measured by hand: python-toml ACCEPTS that + # file and reports its records, so the reference tool is not what + # catches this - the hand written structural check is. + "a toml record number is not handed back, so the ids skip", + "internal/format/tomlfile/toml.go", + "func (r *records) Discard() { r.next-- }", + "func (r *records) Discard() {}", + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + # The same defect on the other format, and pointed at the other layer on + # purpose: one entry proving the structural check and one proving the + # guard that reads the bytes directly. A thing protected twice does not + # move when one layer is mutated, which is how NOT CAUGHT ends up being + # a sentence about the guard rather than about the code. + "a yaml record number is not handed back, so the ids skip", + "internal/format/yamlfile/yaml.go", + "func (r *records) Discard() { r.next-- }", + "func (r *records) Discard() {}", + "TestRecordNumbersRunFromOneWithoutAGap", + ), + ( + # TOML says a document is UTF-8 and both readers refuse one that opens + # with a byte order mark. The label comment is where a mark would ride + # in, and the size stays exact because the filler absorbs it. + "a toml document opens with a byte order mark, which no reader accepts", + "internal/format/tomlfile/toml.go", + 'line := "# " + core.Label("toml", r.Bytes, r.Seed) + "\\n"', + 'line := "\\ufeff# " + core.Label("toml", r.Bytes, r.Seed) + "\\n"', + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + # A JSON fixture exists so a parser meets every value type. Drop one and + # the document is still valid JSON of exactly the right size - only a + # guard that knows which types were promised notices. + "a json record loses its null, so one value type never reaches a parser", + "internal/format/jsonfile/json.go", + "dst = append(dst, r.s.nullPart...)", + "dst = append(dst, r.s.nullPart[:0]...)", + "TestAJSONDocumentIsAnArrayOfRecordsWithEveryType", + ), + ( + # A bare ampersand is the classic way a generated document stops being + # well formed while staying exactly the right size. Both the hand + # written structural check and the strict decoder refuse it. + "the xml ampersand stops being escaped, so the document is not well formed", + "internal/format/xmlfile/xml.go", + 'dst = append(dst, "&"...)', + "dst = append(dst, '&')", + "TestAnXMLDocumentIsWellFormedAndCarriesRecords", + ), + ( + "contains is ignored, so the archive holds the default instead", + "internal/format/archive/archive.go", + "if r.Contains != nil {\n\t\treturn fromContains(id, r, stated)", + "if false {\n\t\treturn fromContains(id, r, stated)", + "TestTheMembersOfAnArchiveAreRealFilesOfEveryFormatAsked", + ), + ( + # Two groups of one format both starting at 0001 means the second + # replaces the first, and the archive quietly holds fewer files than + # the recipe asked for. + "members are numbered per group, so two groups of a format collide", + "internal/format/zip/children.go", + "numbered[g.Format]++", + "numbered[g.Format] = i + 1", + "TestTheMembersOfAnArchiveAreRealFilesOfEveryFormatAsked", + ), + ( + "a format that holds nothing accepts contains anyway", + "internal/engine/engine.go", + "if len(t.Contains) > 0 && !desc.Container {", + "if false {", + "TestContentsThatCannotBeHonouredAreRefusedWithTheRightCode", + ), + ( + # The size stated in the recipe has to win, or an archive comes out a + # different size than the recipe says - the one promise this tool makes. + "the contents override a size the recipe stated", + "internal/recipe/target.go", + "case rt.Size != nil:", + "case rt.Size != nil && rt.Contains == nil:", + "TestASizeStatedBesideContentsWinsAndPaddingClosesTheGap", + ), + ( + # An empty contains says "hold nothing" and no contains says nothing at + # all. Collapsing the two turns a legal empty archive into a refusal. + "an empty contains is read as no contains", + "internal/format/archive/archive.go", + "if r.Contains != nil {\n\t\treturn fromContains(id, r, stated)", + "if len(r.Contains) > 0 {\n\t\treturn fromContains(id, r, stated)", + "TestAnArchiveHoldingNothingIsLegal", + ), + ( + # Untouchable rule 3, D11. The neighbouring stdlib golden file catches + # the ground moving under us. This catches us moving. + "an archive timestamp shifts, so every ZIP anybody generated changes", + "internal/format/zip/zip.go", + "time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)", + "time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + # The label is embedded in the file, so its wording is part of the + # bytes. The guard pins txt with the label and without it, and only + # the first of the two may notice this. + "the self describing label is reworded", + "internal/core/label.go", + '"tfg - %s - %d B - seed %s"', + '"tfg %s %d B seed %s"', + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + # Untouchable rule 7. If this one ever goes green, the tool deletes + # files nobody listed, on somebody else's disk, with no undo. + "cleanup walks the directory instead of the manifest", + "internal/audit/cleanup.go", + "claimed := Claimed(m)", + "claimed := m.Files", + "TestCleanupDoesNotRemoveAFileTheRunNeverPutOnTheDisk", + ), + ( + # The condition is kept and disabled rather than replaced by a + # constant. Replacing it orphans the flag variable, the tree stops + # compiling, and the run reads as caught while proving nothing - which + # is exactly what the build check now refuses. + "cleanup deletes without being asked", + "internal/cli/cleanup.go", + "if !*yes {", + "if !*yes && false {", + "TestCleanupWithoutYesRemovesNothing", + ), + ( + "cleanup removes a file whose content it does not recognise", + "internal/audit/cleanup.go", + "return c.Disposition == Ready || (force && c.Disposition == Changed)", + "return true", + "TestCleanupLeavesAChangedFileAloneUnlessForced", + ), + ( + # The direction that breaks a script rather than a disk: a file that + # was already gone counted as a leftover makes the second run fail. + "a file already gone counts as left behind", + "internal/audit/cleanup.go", + "Blocked: c.Disposition != Absent,", + "Blocked: true,", + "TestCleanupRunTwiceIsNotAnError", + ), + ( + # Removing a name is not destroying a file, and a hard link is the case + # that tells the two apart. Emptying the file first leaves the name gone + # either way, so every other cleanup test stays green while somebody's + # data disappears through a second name the tool never looked at. + "cleanup empties a file instead of only unlinking it", + "internal/audit/cleanup.go", + "if err := os.Remove(full); err != nil {", + "if err := os.Truncate(full, 0); err != nil {", + "TestCleanupUnlinksOurNameWithoutDestroyingWhatIsBehindAHardLink", + ), + ( + # Nothing read the history until 2026-08-04. Commit messages, branch + # names and every version of every file are as public as the code, and + # deleting a file does not take it back. + "the scan for private content stops looking for an account path", + "internal/guard/privatecontent_test.go", + '{"a path inside somebody\'s Windows profile", regexp.MustCompile(`(?i)[A-Za-z]:[\\\\/]{1,2}Users[\\\\/]`)},', + '{"a path inside somebody\'s Windows profile", regexp.MustCompile(`(?i)ZZnevermatchesZZ`)},', + "TestTheScanForPrivateContentCanActuallyFindIt", + ), + ( + # The other direction. A scan that refuses everything is a scan + # somebody switches off, and then it guards nothing at all. + "the scan starts calling an ordinary install path private", + "internal/guard/privatecontent_test.go", + '"user": true, "you": true, "me": true, "name": true,', + '"nobody": true, "you": true, "me": true, "name": true,', + "TestTheScanForPrivateContentLeavesOrdinaryTextAlone", + ), + ( + # The scan has to reach the whole repository. Run from the directory + # the test lives in, git ls-files answers about that directory alone - + # measured 2026-08-04, it read a handful of files and called the tree + # clean. + "the file scan reads only the directory the test sits in", + "internal/guard/privatecontent_test.go", + "cmd.Dir = repoRoot(t)", + "cmd.Dir = \".\"", + "TestNoTrackedFileCarriesPrivateContent", + ), + ( + # Asking git for less leaves the scan looking at forty character hex + # strings, in which it will never find anything - green, and blind. + "the history is read without the subjects and bodies", + "internal/guard/privatecontent_test.go", + 'gitOutput(t, "log", "--all", "--format=%H%n%s%n%b")', + 'gitOutput(t, "log", "--all", "--format=%H")', + "TestNoCommitMessageCarriesPrivateContent", + ), + ( + "the ref scan is pointed at something that is not a list of names", + "internal/guard/privatecontent_test.go", + 'gitOutput(t, "for-each-ref", "--format=%(refname)")', + 'gitOutput(t, "for-each-ref", "--format=%(objecttype)")', + "TestNoBranchOrTagNameCarriesPrivateContent", + ), + ( + # The rule used to be about dashes and semicolons alone, and two things + # walked past it: the red circle this project marks warnings with in its + # own documents, which had reached a comment in internal/core, and a + # typographic ellipsis in internal/guard. Both found 2026-08-04 by + # pulling all 5532 comment lines out of the tree and looking for + # anything above ASCII - a question no rule was asking. + "a symbol from the project's own notes reaches a comment that ships", + "internal/core/records.go", + "// The cut lands on a character boundary, not on a byte.", + "// The cut lands on a character boundary \U0001f534, not on a byte.", + "TestProseInTheRepositoryUsesFlatHyphensAndNoSemicolons", + ), + ( + # Asking is not a mistake. The same class as --help ending with 2: the + # exit code guard stays green either way, because it asks whether a + # code is in the table and not whether it is the right one. + "asking for the licence is treated as a mistyped command", + "internal/cli/commands.go", + 'Aliases: []string{"--license", "--licence", "licence"},', + 'Aliases: []string{"--license", "--licence"},', + "TestAskingForTheLicenceIsAnsweredAndEndsWithZero", + ), + ( + # The notice could shrink to the name of the licence and still look + # like an answer. The sentence about generated files is the reason + # somebody runs this rather than reading LICENSE. + # Moved from internal/cli to internal/version on 2026-08-05, when the + # window started showing the same notice. Two surfaces on one layer + # cannot import each other, so the text went below both of them. + "the licence notice stops saying that generated files are the user's", + "internal/version/version.go", + "The files you generate are yours.", + "The files you generate are covered too.", + "TestAskingForTheLicenceIsAnsweredAndEndsWithZero", + ), + ( + # A notice naming a document that does not ship reads as an answer and + # is not one. + "the notices file stops being part of the repository", + "internal/guard/licence_test.go", + '[]string{"LICENSE", "THIRD-PARTY-NOTICES.md"}', + '[]string{"LICENSE", "NO-SUCH-NOTICES.md"}', + "TestTheFileTheLicenceNoticePointsAtExists", + ), + ( + "the manifest goes even though a file it lists is still there", + "internal/cli/cleanup.go", + "if blocked > 0 {\n\t\tfor i, p := range run.record {", + "if false {\n\t\tfor i, p := range run.record {", + "TestCleanupRemovesTheManifestOnlyWhenAskedAndOnlyWhenEverythingElseWent", + ), + ( + "the manifest is removed without being asked for", + "internal/cli/cleanup.go", + "if *withManifest {", + "if *withManifest || true {", + "TestCleanupRemovesWhatTheManifestListsAndNothingElse", + ), + ( + "verify never looks at content, so an edited file passes", + "internal/audit/audit.go", + "if sum != f.Hashes.SHA256 {", + "if false {", + "TestVerifyCatchesAMissingAnExtraAndAChangedFile", + ), + ( + "verify ignores files nobody asked for", + "internal/audit/audit.go", + "diffs = append(diffs, Difference{Kind: kind, Path: p, Want: want})", + "_, _ = kind, want", + "TestVerifyCatchesAMissingAnExtraAndAChangedFile", + ), + ( + "verify counts a file that was never written as missing", + # A failed entry describes a file the run reported as not produced. + # Claiming it would turn the manifest's own honesty into a false alarm. + "internal/audit/audit.go", + "if f.Failed || !f.Materialized {", + "if false {", + "TestVerifyDoesNotGoLookingForFilesTheRunSaidItNeverWrote", + ), + ( + "verify treats the manifest itself as a file nobody asked for", + "internal/audit/audit.go", + "if seen[p] || filepath.Base(p) == skip || (m.Run.Instructions != \"\" && filepath.Base(p) == m.Run.Instructions) {", + "if seen[p] || (m.Run.Instructions != \"\" && filepath.Base(p) == m.Run.Instructions) {", + "TestVerifyDoesNotReportTheManifestAsAnExtraFile", + ), + ( + "a manifest from an unreadable schema is acted on anyway", + "internal/manifest/manifest.go", + "if got != want {", + "if got != want && false {", + "TestVerifyRefusesAManifestFromASchemaItCannotRead", + ), + ( + # Found on 2026-08-03, after the textual containment check was already + # in place. "jn/VICTIM.txt" holds no climb, so the text passed - and a + # junction called "jn" took cleanup --yes --force one directory up, + # exit code 0. docs/SECURITY.md section 2.4 carried the rule already, + # marked NOT VERIFIED, and it was right. + "a link inside the directory is followed out of it again", + "internal/audit/audit.go", + "if b.Escapes(full) {", + "if b.Escapes(full) && false {", + "TestCleanupDoesNotFollowALinkOutOfTheDirectory", + ), + ( + # Named against the linked-directory guard rather than against an + # escape guard, and the first attempt got that wrong. Resolving the + # base only changes the answer when the output directory is itself a + # link: without it, Rel compares a link against its target and every + # ordinary file in that directory reads as an escape. The escape guards + # use a plain directory, so they stayed green and the runner said so. + # + # Re-pointed on 2026-08-20 when the boundary stopped being resolved per + # entry. The named test had to change with it, and the reason is worth + # keeping: the quick reading now answers a plain file in a linked + # directory without ever consulting the resolved boundary, so + # TestADirectoryReachedThroughALinkStillWorks stayed green under this + # mutation. The resolved boundary is only load bearing where the quick + # reading declines - a redirection below a directory that is itself + # reached through a link - and that is the shape the guard below builds. + "the directory is compared as written instead of as it resolves", + "internal/core/filename.go", + " r, err := resolveAsFarAsItExists(dir)", + " r, err := filepath.Abs(dir)", + "TestALinkInsideTheDirectoryPointingBackInsideItIsNotAnEscape", + ), + ( + # Measured on Windows 2026-08-03: two targets asking for report.txt and + # REPORT.TXT ended with exit 0, one file on the disk and two entries in + # the manifest, and verify then failed on the tool's own output. + "two names differing only in case stop counting as one", + "internal/core/foldname.go", + "return cases.Fold().String(norm.NFC.String(name))", + "return norm.NFC.String(name) + cases.Fold().String(\"\")", + "TestTwoFilesDifferingOnlyInCaseAreRefused", + ), + ( + # The other direction. Comparing without case is not comparing without + # looking - report_a and REPORT_B are two files everywhere. + "the collision check starts refusing names that are not the same", + "internal/core/foldname.go", + "return cases.Fold().String(norm.NFC.String(name))", + "return cases.Fold().String(norm.NFC.String(name[:1]))", + "TestNamesThatDifferByMoreThanCaseStillWork", + ), + ( + # Measured on macOS 26.5.1, APFS, arm64, 2026-08-04: two targets asking + # for cafe.txt with the accent composed and decomposed ended with exit + # 0, one file on the disk, two entries in the manifest, and verify then + # failing on the tool's own output. The same shape as the case defect, + # reached by spelling a letter the other way. + # + # The replacement keeps norm referenced. Dropping the call outright + # orphans the import, Go refuses to build, and a mutation that does not + # compile proves nothing - it reports BROKEN. + "two spellings of one accented name stop counting as one", + "internal/core/foldname.go", + "return cases.Fold().String(norm.NFC.String(name))", + "return cases.Fold().String(name) + norm.NFC.String(\"\")", + "TestTwoNamesDifferingOnlyInUnicodeNormalizationAreRefused", + ), + ( + # The other direction. An accent is ordinary input - only a pair that + # collides is a problem, and refusing every accented name would satisfy + # the rule above while making the tool worse. + "the collision check starts refusing accented names that do not collide", + "internal/core/foldname.go", + "return cases.Fold().String(norm.NFC.String(name))", + "return cases.Fold().String(norm.NFC.String(name[:1]))", + "TestAccentedNamesThatDoNotCollideStillWork", + ), + ( + # Measured on 2026-08-04: a recipe saved as cp1250 was accepted and the + # file arrived with four replacement characters in its name. + "a recipe in any encoding is read as best it can be", + "internal/recipe/recipe.go", + "if !utf8.Valid(src) {", + "if !utf8.Valid(src) && false {", + "TestARecipeThatIsNotUTF8IsRefused", + ), + ( + # And the other way: refusing everything would satisfy that guard. + "the encoding check refuses files that are perfectly good UTF-8", + "internal/recipe/recipe.go", + "if !utf8.Valid(src) {", + "if utf8.Valid(src) {", + "TestARecipeInUTF8KeepsItsLetters", + ), + ( + # Measured on 2026-08-04: "a:b.txt" accepted on Linux, refused on + # Windows, from one recipe - and "AB:c.txt" left an empty file called + # AB behind while reporting the file as not produced. + "a colon in a name is left to the local machine to interpret", + "internal/engine/filename.go", + 'case strings.Contains(name, ":"):', + 'case strings.Contains(name, ":") && false:', + "TestANameIsJudgedTheSameWayOnEverySystem", + ), + ( + "the manifest records a local time, so two machines describe one run differently", + "internal/manifest/manifest.go", + "GeneratedAt: time.Now().UTC().Format(time.RFC3339),", + "GeneratedAt: time.Now().Format(time.RFC3339),", + "TestTheRecordedTimeDoesNotDependOnTheLocalZone", + ), + ( + # O45. The rule binding width and height lived in the generator and in + # a sentence of prose, so "tfg formats png" offered 20000 by 20000 and + # the run refused the pair. A window builds its fields from the machine + # readable list, which had nothing in it to build from. + "the joint limit stops reaching anything that is not prose", + "internal/cli/formats.go", + "Label: string(d.Label), Properties: props, JointLimits: jointsOf(d),", + "Label: string(d.Label), Properties: props,", + "TestTheJointLimitReachesMachineOutput", + ), + ( + # Found on 2026-08-03 by asking for exactly what the tool printed. + # pdf, wav and zip advertised a minimum they then refused, because the + # registry number is the structural floor with no label and the label + # is on unless it is turned off. + "the minimum on screen goes back to a number the tool refuses", + "internal/cli/formats.go", + "\t\td.ID, d.Fidelity, d.Determinism, core.ExactBytes(smallestAccepted(d)))", + "\t\td.ID, d.Fidelity, d.Determinism, core.ExactBytes(d.MinBytes))", + "TestTheMinimumThisToolPrintsIsASizeItAccepts", + ), + ( + # The other direction. A number well above the real floor would satisfy + # the guard above while telling somebody they cannot ask for a file + # they can have. + "the minimum on screen sits above the smallest size that works", + "internal/format/format.go", + "\t\tif _, err := d.Generator.Plan(r); err == nil {\n\t\t\treturn size\n\t\t}", + "\t\tif _, err := d.Generator.Plan(r); err == nil {\n\t\t\treturn size + 1\n\t\t}", + "TestTheMinimumThisToolPrintsIsTheSmallestItAccepts", + ), + ( + # Found on 2026-08-03 with tools/probes/fidelity-sweep.py. The + # reference tool only ever saw one size per format - MinBytes plus + # 300 KB - so SVG at exactly its minimum rendered to a single flat + # colour and every guard stayed green. Dropping the minimum back puts + # that file back in reach. + "the smallest drawing a format will make paints nothing again", + "internal/format/svgfile/svg.go", + "return int64(len(declaration)+len(rootOpen(w, h))) + s.Shortest() + 1", + "return int64(len(declaration)+len(rootOpen(w, h))) + s.Shortest()", + "TestEveryFormatSurvivesItsReferenceToolAtItsSmallest", + ), + # Not listed: weakening the shape counter in TestAnSVGDrawingCarriesRealShapes + # back to counting an empty text element. The runner reported NOT CAUGHT and + # was right - every size that guard now walks holds real text, so the + # weakening changes no verdict. The input that would expose it was 193 B, + # and that size stopped existing with the fix above. Same shape as O25: the + # runner proving a repair rather than finding a hole. + ( + # Found by audit on 2026-08-03. "--size 1e5" quietly meant 100000 bytes + # while a recipe refuses "1_000" for the opposite reason, and the + # difference fell out of ParseFloat being permissive rather than out of + # a decision. + "a size goes back to accepting any spelling ParseFloat takes", + "internal/core/size.go", + "if !plainDecimal(digits) {", + "if !plainDecimal(digits) && false {", + "TestASizeIsWrittenTheWayAPersonWritesIt", + ), + ( + "the recorded command splits an argument that holds a space", + "internal/cli/cli.go", + "out = append(out, quoteArg(withoutSecret(a, afterSet, secrets)))", + "out = append(out, withoutSecret(a, afterSet, secrets))", + "TestTheRecordedCommandCanBeRunAgain", + ), + ( + # The temporary file is created with os.Create, which truncates, and + # the collision check only ever looked at the name a file ends up with. + "the name a file is written through is not checked for a collision", + "internal/engine/preflight.go", + "if path := tempPathFor(opt.OutDir, f.Name); isTaken(taken, path, tempNameFor(f.Name)) {", + "if path := tempPathFor(opt.OutDir, f.Name); isTaken(taken, path, tempNameFor(f.Name)) && false {", + "TestAFileUnderTheTemporaryNameIsNotWrittenOver", + ), + ( + # "tfg generate --json | head" is the ordinary way this happens, and + # the error was dropped on the floor. + "a manifest that never reached standard output is reported as a success", + "internal/cli/generate.go", + "if _, err := out.Write(buf.Bytes()); err != nil {", + "if _, err := out.Write(buf.Bytes()); err != nil && false {", + "TestAManifestThatCannotReachStandardOutputIsNotASuccess", + ), + ( + "a CI job loses its ceiling and can hold a runner for six hours", + ".github/workflows/ci.yml", + " timeout-minutes: 30", + " # timeout-minutes: 30", + "TestEveryCIJobHasACeilingOnItsTime", + ), + ( + "nothing builds the window binary, so it can stop compiling in silence", + ".github/workflows/ci.yml", + # Both branches, because either one alone still builds the window and + # the property really does survive. Measured on 2026-08-20: mutating + # only the Windows line reported NOT CAUGHT, and the guard was right - + # the else branch was still building it. + ' go build -tags \"$(cat .github/build-tags)\" -ldflags="$(cat .github/gui-ldflags)" ./cmd/tfg-gui\n else\n go build -tags \"$(cat .github/build-tags)\" ./cmd/tfg-gui', + ' go build -tags \"$(cat .github/build-tags)\" -ldflags="$(cat .github/gui-ldflags)" ./cmd/tfg\n else\n go build -tags \"$(cat .github/build-tags)\" ./cmd/tfg', + "TestTheWindowBinaryIsBuiltSomewhere", + ), + ( + # The job's -run pattern no longer names the test. go test with a + # pattern matching nothing exits 0 saying "no tests to run", the matrix + # skips the guard by its variable, and the import table is read by + # nobody - which is the whole premise of the software renderer (O218). + "the job that reads the import table misspells the test it exists to run", + ".github/workflows/ci.yml", + "-run '^TestTheWindowBinaryDoesNotImportOpenGLAtLoadTime$'", + "-run '^TestTheWindowBinaryDoesNotImportOpenGLAtLoadTim$'", + "TestTheImportTableGuardIsRunByTheJobThatNamesIt", + ), + ( + "the test matrix no longer asks the import table guard to skip, so the cold cgo build is back in it", + ".github/workflows/ci.yml", + ' TFG_IMPORT_TABLE_JOB: "1"', + ' TFG_IMPORT_TABLE_JOBS: "1"', + "TestTheImportTableGuardIsRunByTheJobThatNamesIt", + ), + ( + # A step that no longer reads its log for the PASS line is green when + # the guard skips on a runner without gcc and when the pattern matches + # no test - both measured to exit 0. + "the import table job no longer reads its log for the PASS line of the one test it runs", + ".github/workflows/ci.yml", + " grep -q -- '--- PASS: TestTheWindowBinaryDoesNotImportOpenGLAtLoadTime' import-table.log", + " true", + "TestTheImportTableGuardIsRunByTheJobThatNamesIt", + ), + ( + # The wording reverted to the finding it used to be. The guard beside + # it reads the expected text from the function, so only this one goes + # red - which is what the outside review of #117 asked for. + "the line before the second start states the missing driver as a fact again", + "internal/gui/text/text.go", + "The first attempt to open a window did not succeed - usually a graphics driver without OpenGL 2.1. Starting again", + "The graphics driver on this computer offers no OpenGL 2.1. Starting again", + "TestTheSecondStartSpeaksAsANoteNotAFinding", + ), + ( + "the window binary is linked as a console program, so Windows opens a black terminal beside it", + ".github/gui-ldflags", + "-H windowsgui", + "-s", + "TestTheWindowBinaryStartsWithoutAConsole", + ), + ( + # A box that is not showing keeps what was typed into it, so that + # changing your mind twice does not cost the value. Sending it anyway is + # the whole risk of that: the run refuses a batch stating two sizes, and + # the refusal names a box nobody can see. O114 through the back door. + "a way of stating a size is sent even when the switch is not on it", + "internal/gui/window/recipesize.go", + " if b.chosenSizeKey() != key {", + " if false && b.chosenSizeKey() != key {", + "TestOnlyTheChosenWayOfStatingASizeReachesTheRun", + ), + ( + "the room is worked out and never reaches the list, so shortening it does nothing at all", + "internal/gui/parts/openlist.go", + "if l.room > 0 && height > l.room {", + "if l.room > 0 && height > l.room && false {", + "TestAnOpenListIsCutToTheRoomThereIs", + ), + ( + "an open list goes under the box at full height again, straight through the bottom of the window", + "internal/gui/parts/ring.go", + "\tif rows <= below || below >= listOpensDownwardFrom*listRowHeight() || below >= above {", + "\tif rows <= below || true {", + "TestAnOpenListStaysInsideTheWindow", + ), + ( + "adding a batch goes back into the scrolling form, where one batch is taller than the window", + "internal/gui/window/recipe.go", + # Repointed 2026-10-14, when GitHub joined Donate in the bar. + "r.footer(rail(append(append(outwardButtons(host), parts.Divider(), r.addBtn), links...)...)),", + "r.footer(rail(append(outwardButtons(host), links...)...)),", + "TestAddingABatchIsReachableWithoutScrolling", + ), + ( + # What this proves and what it does not, because the difference matters + # here. It proves the guard is live: with a mapping accepted as a single + # value, a wrong shape stops being refused as a recipe problem and gets + # judged as content instead, and the guard says so. + # + # It does NOT reproduce the original defect, and no compiling mutation + # can. That defect was the parser answering for a typed field, so + # recreating it means changing a field back from scalar to string - and + # that does not compile, which the runner reports as BROKEN and which + # proves nothing. Measured on 2026-08-20 rather than assumed. + "a mapping passes as a single value, so a wrong shape is judged as content", + "internal/recipe/scalar.go", + 'if !s.single() {\n\t\treturn "", false', + 'if !s.single() && false {\n\t\treturn "", false', + "TestNoRecipeKeyAnswersWithTheWordsOfTheParser", + ), + ( + "the trailer filter swallows the whole message, so an address in a body goes unread", + "internal/guard/privatecontent_test.go", + "if identityTrailer.MatchString(line) {", + "if identityTrailer.MatchString(line) || true {", + "TestAnAddressInTheTextOfAMessageIsStillFound", + ), + ( + "an en dash reaches README, which is exempt from the ASCII rule but not from rule 13", + "README.md", + "part this replaces", + "part this replaces – ", + "TestProseInTheRepositoryUsesFlatHyphensAndNoSemicolons", + ), + ( + "a batch can be added while a run is writing, which rebuilds the form the run is going through", + "internal/gui/window/recipe.go", + "r.runner.busy.also = append(r.runner.busy.also, r.addBtn)", + "r.runner.busy.also = append(r.runner.busy.also)", + "TestAddingABatchIsOutOfUseWhileARunIsGoing", + ), + ( + "the licence notice points only at files, so somebody with just the binary is sent nowhere", + "internal/version/version.go", + "https://www.gnu.org/licenses/gpl-3.0.html, and in the LICENSE file if you have", + "the LICENSE file, if you have", + "TestTheLicenceNoticePointsSomewhereThatAlwaysExists", + ), + ( + "the batch screen stops saying what an empty count box will do", + "internal/gui/window/recipe.go", + "b.count.SetPlaceHolder(text.PlaceholderLeftEmpty(strconv.Itoa(recipe.DefaultCount)))", + "b.count.SetPlaceHolder(text.PlaceholderLeftEmpty(strconv.Itoa(recipe.DefaultCount + 1)))", + "TestTheBatchScreenSaysWhatAnEmptyBoxWillDo", + ), + ( + "the form stays editable while a run writes, so changing a setting means nothing anybody can predict", + "internal/gui/window/runbusy.go", + "\tb.worn = true\n\tb.fields.Freeze(true)", + "\tb.worn = true\n\tb.fields.Freeze(false)", + "TestTheFormCannotBeEditedWhileARunIsGoing", + ), + ( + "a refusal leaves the form where it was, so a marked box off the screen reads as a dead button", + "internal/gui/window/runrefuse.go", + "if field := r.fields.Lookup(first); field != nil {", + "if field := r.fields.Lookup(first); field != nil && false {", + "TestARefusalBringsTheBoxItIsAboutIntoView", + ), + ( + "a refusal placed entirely on the boxes says nothing where the button was pressed", + "internal/gui/window/runrefuse.go", + "if len(loose) == 0 {", + "if len(loose) == 0 && false {", + "TestARefusalBringsTheBoxItIsAboutIntoView", + ), + ( + "looking inside a control stops at the wrapper, so freezing and revealing silently do nothing", + "internal/gui/parts/reveal.go", + "for _, child := range box.Objects {", + "for _, child := range box.Objects[:0] {", + "TestTheFormCannotBeEditedWhileARunIsGoing", + ), + ( + "a menu spells its values out in prose as well as offering them, on a screen that already does not fit", + "internal/gui/parts/property.go", + "if p.Kind == format.PropertyChoice {", + "if p.Kind == format.PropertyChoice && false {", + "TestTheWindowDrawsAFieldForEveryDeclaredProperty", + ), + ( + "the window offers the directory it was started from, so a double clicked run fills somebody's Downloads", + "internal/gui/window/offered.go", + "return filepath.Join(working, OutputFolderName)", + "return filepath.Join(working)", + "TestTheWindowOffersAFolderOfItsOwnToWriteInto", + ), + ( + "the window compares its own directory by text, so a double click reached under another spelling offers the program folder", + "internal/gui/window/offered.go", + "return os.SameFile(ia, ib)", + "return os.SameFile(ia, ib) && a == b", + "TestTheWindowOffersTheHomeDirectoryWhenStartedWhereItShouldNotWrite", + ), + ( + "the window forgets the root of a disk, so macOS started from Finder offers a read only /tfg-out", + "internal/gui/window/offered.go", + "return isRoot(dir) || sameDirectory(dir, program)", + "return sameDirectory(dir, program)", + "TestTheWindowOffersTheHomeDirectoryWhenStartedWhereItShouldNotWrite", + ), + ( + "without a home directory the window makes up a path instead of offering the one it always did", + "internal/gui/window/offered.go", + 'if home != "" && notMeantForWriting(working, program) {', + "if notMeantForWriting(working, program) {", + "TestTheWindowOffersTheHomeDirectoryWhenStartedWhereItShouldNotWrite", + ), + ( + "any remembered folder under the program directory counts as left by the old offer, so a chosen one is thrown away", + "internal/gui/window/offered.go", + "if filepath.Base(remembered) != OutputFolderName {", + "if false {", + "TestAFolderTheOldOfferLeftBehindIsNotOfferedAgain", + ), + ( + "the window hands the screens the folder the old offer left behind, so /tfg-out comes back at every start", + "internal/gui/window/open.go", + 'if last == "" || LeftByTheOldOffer(last, programDirectory()) {', + 'if last == "" {', + "TestTheWindowDoesNotOfferTheFolderTheOldOfferLeftBehind", + ), + ( + "the window stops saying where the files will go, so the only field that decides it is off the screen", + "internal/gui/window/runline.go", + " return text.WritingTo(l.where(dir))", + " return \"\"", + "TestWhereTheFilesGoIsSaidOutsideTheScrollingPart", + ), + ( + "a run's messages take their room only once there is a run, so the form jumps when one starts", + "internal/gui/window/run.go", + "return parts.WithRoomForARun(container.NewVBox(bar, parts.Flush(status), problem.Object()))", + "return container.NewVBox(bar, parts.Flush(status), problem.Object())", + "TestTheFormDoesNotMoveWhenARunStarts", + ), + # REMOVED 2026-08-20. It took the slimming wrapper off the track and was + # reported NOT CAUGHT by the full run, correctly: the track became a control + # of ours that carries its own height, so the wrapper had stopped doing + # anything and taking it off broke nothing. The wrapper is gone with it. + # A mutation that cannot break the program is not proof of a guard. + ( + "the room kept for a run's messages holds no line at all, so the bar still grows", + "internal/gui/parts/parts.go", + 'NewProgress(), widget.NewLabel("Ag")', + "NewProgress()", + "TestTheRoomKeptForARunHoldsTheBarAndALine", + ), + ( + "the workflow writes the linker flags out by hand, so the release build can drift from it", + ".github/workflows/ci.yml", + ' go build -tags \"$(cat .github/build-tags)\" -ldflags="$(cat .github/gui-ldflags)" ./cmd/tfg-gui', + " go build -tags \"$(cat .github/build-tags)\" ./cmd/tfg-gui # was: with the flags", + "TestTheWorkflowBuildsTheWindowWithTheFlagsTheRepositoryDeclares", + ), + ( + # The formatter writes through a temporary name so that a process + # ending mid write does not leave a recipe at half its length. + "settling a recipe leaves the temporary file beside it", + "internal/core/replace.go", + "if err := os.Rename(tmp, path); err != nil {", + "if err := error(nil); err != nil {", + "TestSettlingARecipeLeavesNothingBesideIt", + ), + ( + # Found by audit on 2026-08-03. "--count -5" was reported as "asks for + # 0 files", because anything below one built an empty list and the + # planner then described the list rather than the request. + "a count below one is described by the empty list it produces", + "internal/cli/generate.go", + 'if given["count"] && g.count < 1 {', + 'if given["count"] && g.count < 1 && false {', + "TestACountBelowOneIsReportedWithTheNumberThatWasWritten", + ), + ( + # Two messages for one fault, the first saying "there is nothing at + # that path" about a path with something at it. + "pointing the output at a file falls through to the system message", + "internal/engine/preflight.go", + "if info, err := os.Stat(opt.OutDir); err == nil && !info.IsDir() {", + "if info, err := os.Stat(opt.OutDir); err == nil && !info.IsDir() && false {", + "TestOutPointingAtAFileSaysSoOnce", + ), + ( + # The same class closed for declared ranges on 2026-08-03, surviving one + # layer deeper: the declaration bounds each side on its own and cannot + # express a limit on the two multiplied, so this ended with 1. + # This one targets the sentence rather than the type. Wrapping the + # error to change its class does not compile - the struct literal + # cannot be closed by a swap of one line - and the runner reported + # BROKEN, which proves nothing. The exit code half of that guard rides + # on the classify mutation listed further up, which turns every + # PropertyValueError back into a RUNTIME ending. + "the refusal stops saying which limit the two settings ran into", + "internal/format/png/png.go", + 'Unit: "megapixels", Per: 1_000_000,', + 'Unit: "", Per: 1_000_000,', + "TestAPictureTooLargeToHoldIsTheCallersRequest", + ), + ( + "the format list stops admitting the limit on two settings at once", + "internal/cli/formats.go", + "for _, j := range d.JointLimits {\n\t\tfmt.Fprintf(out,", + "for _, j := range d.JointLimits[:0] {\n\t\tfmt.Fprintf(out,", + "TestTheFormatListAdmitsTheLimitOnTwoSettingsAtOnce", + ), + ( + # The command line could not state a reason at all, so a run driven by + # flags could never fill the category the closed list exists to count. + "the reason a flag states never reaches the manifest", + "internal/cli/generate.go", + "ExpectedReason: g.expectedReason,", + 'ExpectedReason: "",', + "TestTheCommandLineCanStateWhyAnOutcomeIsExpected", + ), + ( + "the command line stops checking the reason against the closed list", + "internal/cli/generate.go", + 'if g.expectedReason != "" && !recipe.KnownReason(g.expectedReason) {', + 'if g.expectedReason != "" && !recipe.KnownReason(g.expectedReason) && false {', + "TestAReasonNobodyRecognisesIsRefusedOnBothSurfaces", + ), + ( + "a reason with no outcome is carried into the manifest describing nothing", + "internal/cli/generate.go", + 'if g.expectedReason != "" && g.expected == "" {', + 'if g.expectedReason != "" && g.expected == "" && false {', + "TestAReasonWithNoOutcomeIsRefused", + ), + ( + # Found by fuzzing on 2026-08-03, by a target written that day. The + # printer in the dependency is not faithful for everything the parser + # takes: "*0000000 : 000" prints as something that does not parse. With + # this check gone, "recipe fmt -w" replaces the user's file with it and + # reports success. + "the formatter hands back a shape it never read back", + "internal/recipe/canonical.go", + "again, err := oneDocument(withoutBOM(out), name)", + "again, err := oneDocument(withoutBOM(src), name)", + "TestTheFormatterNeverWritesAFileItCannotReadBack", + ), + ( + # The other direction. A check that refuses everything would satisfy + # the guard above and stop the formatter working at all. + "the formatter refuses every file it is given", + "internal/recipe/canonical.go", + "if again.String() != string(out) {", + "if again.String() == string(out) {", + "TestAnOrdinaryRecipeStillSettlesAndStaysSettled", + ), + ( + # Found by tools/probes/hard-kill-probe.py on 2026-08-03. Three runs + # killed with taskkill /F left one of these every time, and verify + # called it "extra" - a word that says nothing about a file this tool + # wrote itself, in a directory cleanup will never touch. + "our own leftover is reported as a file we know nothing about", + "internal/audit/audit.go", + # Same reason as the entry about the second marker: the case list moved + # onto three lines on 2026-09-07, so this takes the first of them. + "core.IsPartialName(base)", + "core.IsPartialName(base) && false", + "TestALeftoverFromAKilledRunIsNamedForWhatItIs", + ), + ( + # The other direction. Calling everything a leftover would satisfy the + # guard above and tell somebody their own file was ours. + "somebody else's file is described as our leftover", + "internal/audit/audit.go", + 'return Extra, ""', + 'return Leftover, ""', + "TestAFileNobodyAskedForIsStillReportedAsExtra", + ), + ( + # The junction is the case the symbolic link guard could not see. + # EvalSymlinks returns a junction unchanged and reports no error, so the + # comparison said "inside" while the filesystem went elsewhere - and the + # suite was green while the escape still worked. Measured 2026-08-03. + "an unresolved reparse point is walked through rather than refused", + "internal/core/filename.go", + "if fi.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {", + "if fi.Mode()&os.ModeSymlink != 0 {", + "TestCleanupDoesNotFollowAJunctionOutOfTheDirectory", + ), + ( + "verify walks through a junction instead of refusing it", + "internal/core/filename.go", + "return crossesUnresolvedLink(b.resolved, rel)", + "return false", + "TestVerifyDoesNotFollowAJunctionOutOfTheDirectory", + ), + ( + "verify reports a clean directory instead of refusing an entry that leaves it", + "internal/audit/audit.go", + "p, err := resolved(b, f)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}", + "p, err := resolved(b, f)\n\t\tif err != nil {\n\t\t\treturn full, nil\n\t\t}", + "TestVerifyDoesNotFollowALinkOutOfTheDirectory", + ), + ( + # The quick reading added on 2026-08-20 can only ever answer "inside". + # That is the safety property the whole change rests on: it cannot + # wrongly refuse, only wrongly accept - so this is the mutation that + # matters, and it makes it accept a junction. + "the quick reading accepts a redirection below the directory", + "internal/core/filename.go", + "&& !crossesUnresolvedLink(b.resolved, rel) {", + "&& true {", + "TestTheDirectoryMayBeNamedWithARelativePath", + ), + ( + # The other half of the same shortcut, and the one that nearly shipped. + # A link inside the directory pointing at another file inside it has + # left nothing, and the tool accepted it before. Answering from the + # walk alone would turn a contained directory into exit 5. + "a redirection below the directory is refused without asking where it points", + "internal/core/filename.go", + " return b.escapesTheThoroughWay(full)", + " return true", + "TestALinkInsideTheDirectoryPointingBackInsideItIsNotAnEscape", + ), + ( + # No verdict changes here, only the cost - filepath.Rel refuses a + # relative path against an absolute one, so comparing against the name + # as typed sends every "./out" run down the expensive reading with the + # right answer. Nothing behavioural can see it, which is why the guard + # reads the source. + # The reserve is a floor and the scroll is the ceiling. Without the + # scroll the box grows with the message again, which is what took 19 px + # of form per extra line until 2026-08-20. A ceiling in reserving.MinSize + # was tried alongside and taken out: it changed no measurement and could + # not be reddened, because a scroll asks for almost nothing anyway. + # The order stopped being cosmetic on the day the room became a ceiling. + # With the message scrolling inside a fixed height, the first line is + # the only one certain to be seen - and a finished run was showing "no + # limit was given..." with "7 files written." out of sight below it. + # + # The first version of the guard checked the first line for the word + # "file" and stayed green under this mutation, because the note talks + # about files too. It matches the tail of the preview's own sentence now. + "a note about a default is put above the sentence somebody pressed the button for", + "internal/gui/window/run.go", + "append(append([]string{}, lines...), r.notes...)", + "append(append([]string{}, r.notes...), lines...)", + "TestWhatARunSaysComesBeforeWhatSettlingSaid", + ), + ( + "a run's messages push the form instead of scrolling inside their own room", + "internal/gui/parts/parts.go", + " inside := container.NewVScroll(content)", + " inside := content", + "TestTheFormDoesNotMoveWhenARunStarts", + ), + ( + # The vertical scale, added 2026-08-20 after the design audit measured + # sixteen consecutive gaps down one card, all of them 20 to 27 px. A + # form that spaces "one field" and "a new group" the same reads as a + # wall of text. Since 2026-09-22 the name stands over its box again, + # so the mutation is the one a stacked form has: the name as far from + # its box as the next field is, and nothing says which box it names. + "a field's name stands as far from its box as the next field does", + "internal/gui/parts/fieldstack.go", + "\treturn container.New(fieldStack{column{gap: GapLabel}}, pieces...)", + "\treturn container.New(fieldStack{column{gap: GapField}}, pieces...)", + "TestANameStandsOverItsBoxOnOneEdgeWithEveryOther", + ), + ( + # The defect exactly as it stood: one screen handed Screen a box of + # panels instead of the panels, so the section spacing landed around + # the box. 7 px here against 23 px on the preset screen. + "one screen wraps its panels, so the space between them is its own", + "internal/gui/window/generate.go", + "\t\t\tg.settingsSection()...,", + "\t\t\tcontainer.NewVBox(g.settingsSection()...),", + "TestTwoSectionsAreTheSameDistanceApartOnEveryScreen", + ), + ( + "the boundary between two sections is drawn no wider than the one between two fields", + "internal/gui/parts/parts.go", + "return Column(GapSection, panels...)", + "return Column(GapField, panels...)", + "TestTheGapBetweenSectionsIsWiderThanTheGapBetweenFields", + ), + ( + # Alignment, added 2026-08-20. Six pixels is the worst of the three + # distances a title can stand at: aligned reads as deliberate, a real + # indent reads as deliberate, six pixels reads as nobody having looked. + "the screen title stands on the panel's edge instead of on its content", + "internal/gui/parts/parts.go", + "Stacked(append([]fyne.CanvasObject{Indented(head)}, sections...)...))", + "Stacked(append([]fyne.CanvasObject{head}, sections...)...))", + "TestEverythingAPersonReadsStartsOnOneLeftEdge", + ), + ( + # The report panel loses the padding every other panel has, so what a + # run says starts at the panel's edge while the boxes above it start + # at its content. Two left edges a few pixels apart in one column. + # + # Repointed on 2026-09-08, in the evening. The version above aimed at + # the rebuilt window and went away with it when gui/new-look was + # deleted - the guard it named does not exist on main, and neither + # does raisedSurface. The guard that DOES exist asks the same + # question of the same code, so the entry moves rather than dies. + "the action bar's column loses its indent and speaks from the panel edge", + "internal/gui/parts/parts.go", + "\tcolumn := container.New(readableWidth{}, Indented(inner))", + "\tcolumn := container.New(readableWidth{}, inner)", + "TestTheActionBarSpeaksOnTheSameEdgeAsTheForm", + ), + ( + # The success and warning colours were in both palettes from the day the + # palette was written and reached the screen for the first time on + # 2026-08-20. Until then "3 files written." was drawn in exactly the + # grey the neutral line beside it uses. + "a finished run goes back to being drawn like any other sentence", + "internal/gui/window/run.go", + " r.toneOfOutcome(res, runErr)", + " _ = res", + "TestAFinishedRunIsColouredLikeAFinishedRun", + ), + ( + "the colour of one run's outcome is left on the line for the next one", + "internal/gui/window/run.go", + " status.Importance = widget.MediumImportance\n" + " status.SetText(said)", + " status.SetText(said)", + "TestTheColourOfAnOutcomeDoesNotOutliveIt", + ), + ( + # An enum set on a widget proves the field was set. This asks whether + # setting it changes the drawing at all. + "the success colour is the ordinary text colour, so saying so draws nothing", + "internal/gui/parts/theme.go", + "theme.ColorNameSuccess: hex(0x6F, 0xCF, 0x7F),", + "theme.ColorNameSuccess: hex(0xE6, 0xE6, 0xE6),", + "TestTheOutcomeColoursAreThreeDifferentColours", + ), + ( + # The toolkit draws the groove in the accent colour at half alpha, so a + # bar at nothing was a solid blue bar. Measured 2026-08-20: the empty + # track stood against the panel at 2.80 while the fill stood against + # the track at 2.49 - louder empty than full. + "the groove goes back to being drawn in the colour of the fill", + "internal/gui/parts/progress.go", + "r.track.FillColor = PaletteColour(theme.ColorNameInputBackground, theme.VariantDark)", + "r.track.FillColor = PaletteColour(theme.ColorNamePrimary, theme.VariantDark)", + "TestTheFilledPartOfAProgressBarIsLouderThanTheEmptyPart", + ), + ( + "the fill is drawn the whole width whatever the value is", + "internal/gui/parts/progress.go", + " width := size.Width * share", + " width := size.Width", + "TestAProgressBarDrawsWhatItWasToldAndNotMore", + ), + ( + # Disabled and placeholder were one colour, so a run in flight drew the + # value somebody chose in the same grey as the hint in an empty box. + "a switched off value goes back to being drawn like a hint", + "internal/gui/parts/theme.go", + "theme.ColorNameDisabled: hex(0xC2, 0xC8, 0xCD),", + "theme.ColorNameDisabled: hex(0x9D, 0xA3, 0xA8),", + "TestAValueSwitchedOffIsItsOwnStepBetweenAValueAndAHint", + ), + ( + # Which side of the hint the step falls on is the decision, not the + # value: a disabled value is content somebody typed. + "the light palette puts a switched off value further away than a hint", + "internal/gui/parts/theme.go", + "theme.ColorNameDisabled: hex(0x3D, 0x3D, 0x3D),", + "theme.ColorNameDisabled: hex(0x8A, 0x8A, 0x8A),", + "TestASwitchedOffValueIsEasierToReadThanAHint", + ), + ( + # A box is a promise about what goes in it. Measured 2026-08-20: the + # width box of a BMP was 806 px for a number from 1 to 20000, on a + # screen where "how many" was 140. + # + # The replacement is Sized(ColumnWidth) rather than the obvious + # "return control", and that is O131. Dropping the wrapper used to + # give the box the whole column, so it read as the plain way to put + # the defect back. Measured 2026-08-26 off a diagnostic inside the + # guard package: it now lays every numeric box out at 0 or -3 px, + # because the row stopped stretching an unsized child somewhere + # after 2026-08-20. That is the opposite failure from the one this + # guard asks about, so the guard stayed green honestly - and the + # entry beside this one already proves the too-narrow direction. + "a box for a number goes back to running the width of the form", + "internal/gui/parts/property.go", + " return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p))), control)", + " return Sized(ColumnWidth, control)", + "TestABoxForANumberIsNotAsWideAsTheForm", + ), + ( + # The same defect the other way up, and it happened on the first try: + # at the plain number width, "worked out from the size" was cut off + # mid-word. + "a box for a number is too narrow to show its own placeholder", + "internal/gui/parts/property.go", + " return Sized(fyne.Max(NumericWidth, roomFor(leftAlone(p))), control)", + " return Numeric(control)", + "TestABoxIsWideEnoughForItsOwnPlaceholder", + ), + ( + # Two screens draw a field from the same declaration through different + # code, and only one of them got the width on the first try. + "the batch screen draws a number in a box the width of the form", + "internal/gui/window/recipefolds.go", + " parts.ShapedFor(b.declared[i], f.Control))", + " f.Control)", + "TestABoxForANumberIsNotAsWideAsTheFormOnTheBatchScreen", + ), + ( + # Two naming systems in one visual style: Format, Size, How many on one + # side and settings for bmp, width, bit_depth on the other, same weight + # at the same size. + "a declared setting wears its recipe key as its label again", + "internal/gui/parts/property.go", + " field := into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(owner, p),", + " field := into.Add(p.Name, p.Name, PropertyDetail(owner, p),", + "TestNoSettingWearsItsRecipeKeyAsALabel", + ), + ( + # What makes the label change a translation rather than a loss: the key + # is the only spelling that works in a recipe. + "the recipe key of a setting is nowhere on the screen", + "internal/gui/parts/property.go", + " tips.Say(text.WrittenAs(owner, p.Name)), ShapedFor(p, f.Control))", + " NoDetail, ShapedFor(p, f.Control))", + "TestTheRecipeKeyOfASettingIsStillReachable", + ), + ( + # A refusal has four parts, so it is a sentence. Confined to one column + # of a row it wrapped onto four lines with half the panel empty beside + # it, measured 2026-08-20. + "a refusal goes back into the column its field sits in", + "internal/gui/parts/table.go", + " return Column(GapTight, append([]fyne.CanvasObject{Row(bodies...)}, areas...)...)", + " return Row(objects...)", + "TestARefusalIsAsWideAsTheFormRatherThanItsColumn", + ), + ( + # The engine names a setting by its recipe key, which is the only name + # the command line has. This window calls that box something else. + "a refusal names the box by the key a recipe writes", + "internal/gui/parts/fields.go", + " f.Say(inTheWordsOnScreen(f, err))", + " f.Say(err.Error())", + "TestARefusalNamesTheBoxTheWayTheScreenNamesIt", + ), + ( + # The switch stops taking the other two off the screen, so three ways of + # stating one size stand side by side again - which is the arrangement + # O114 was written about, and the sentence that warned about it is gone. + "the switch leaves every way of stating a size on the screen at once", + "internal/gui/window/recipesize.go", + " box.Hide()", + " box.Show()", + "TestEveryWayOfStatingASizeIsOfferedAndOnlyOneIsOnTheScreen", + ), + ( + # The recipe reader words a batch with no size as "target 1 has no size" + # however it was meant to be stated, so with the switch on a range that + # refusal is addressed to a box nobody can see. The form then refuses to + # run and marks nothing, which reads as a button that did not work. + "a refusal about a size lands on the box the switch is not showing", + "internal/gui/window/recipe.go", + " r.runner.readdress = r.readdressSizeWay", + " r.runner.readdress = nil", + "TestAStarIsOnEveryBoxTheRunWillNotDoWithout", + ), + ( + # Measured 2026-08-20: the selected tab stood at 7.71 against the page + # and the three nobody was on stood at 13.36, so the chosen one was the + # quietest label in the strip. The strip is ours since 2026-09-15 and + # the word draws its own colour, so the defect in its new shape is the + # chosen word drawn as quietly as the rest. + "the chosen word on the strip goes as quiet as the rest", + "internal/gui/parts/tabs.go", + "\tif r.word.chosen || r.word.hovered {\n\t\tr.text.Color", + "\tif r.word.hovered {\n\t\tr.text.Color", + "TestTheTabSomebodyIsOnIsTheOneThatStandsOut", + ), + ( + # The card and its first field were both called Preset - the same word + # twice inside 40 px, in two ranks of type. + "a section goes back to being named after the field inside it", + "internal/gui/text/screens.go", + 'func SectionPreset() string { return say("SectionPreset", "The question") }', + 'func SectionPreset() string { return say("SectionPreset", "Preset") }', + "TestNoSectionIsNamedAfterAFieldInsideIt", + ), + ( + # Deleting the title also satisfies the guard above, which is why the + # other half is guarded too. + "the preset card loses the name that says what it asks", + "internal/gui/window/preset.go", + # Repointed 2026-09-23, when every section of a work screen started + # to fold (window/sections.go). + "p.sections.section(sectionPreset, text.SectionPreset(),", + 'p.sections.section(sectionPreset, "",', + "TestThePresetCardIsNamedForWhatItAsks", + ), + ( + # The two least related controls in the window stood side by side in one + # style at the left of the bar, so proximity said they were a pair. + "Add a batch goes back to standing as a pair with the button before it", + "internal/gui/window/recipe.go", + # Repointed 2026-10-14, when GitHub came to stand between the two. + "append(outwardButtons(host), parts.Divider(), r.addBtn)", + "append(outwardButtons(host), r.addBtn)", + "TestAddABatchIsNotDrawnAsAPairWithTheButtonBeforeIt", + ), + ( + # A table kept by hand goes stale in silence - O80 names two of those + # already. The twenty-first format joins this one on the day it is + # registered or the suite goes red. + "a registered format is left out of the table that says what kind of file it is", + "internal/gui/parts/filekind.go", + '"svg": kindPicture,', + "", + "TestEveryRegisteredFormatHasAKind", + ), + ( + # The toolkit's file icons are one sheet of paper with different creases + # at the size a row draws them, so six rows carried one icon. An icon + # that looks like information and is not is worse than none. + "two kinds of file go back to being drawn with one picture", + "internal/gui/parts/filekind.go", + " return theme.MediaPhotoIcon()", + " return theme.ListIcon()", + "TestTwoKindsOfFileAreNotDrawnWithOnePicture", + ), + ( + # The inset that put the bar's words on the form's left edge was applied + # on all four sides, so it went in twice vertically and took the bar + # from 134 px to 146 on a screen where the form is already too tall. + "the action bar is inset twice, so it costs the form twelve pixels for nothing", + "internal/gui/parts/parts.go", + "\tcolumn := container.New(readableWidth{}, Indented(inner))", + "\tcolumn := container.New(readableWidth{}, Padded(InsetBar, Indented(inner)))", + "TestTheActionBarCostsItsContentPlusOneInset", + ), + ( + # The reserve is why the form does not jump when Generate is pressed, + # and taking it out is the cheapest way to make a bar shorter. The + # replacement keeps the name in use so the package still compiles. + "the room kept for a run is not kept at all", + "internal/gui/parts/parts.go", + "func WithRoomForARun(content fyne.CanvasObject) fyne.CanvasObject {", + "func WithRoomForARun(content fyne.CanvasObject) fyne.CanvasObject { return content }\n" + "func unusedRoomForARun(content fyne.CanvasObject) fyne.CanvasObject {", + "TestTheActionBarStillReservesRoomForARun", + ), + ( + # Two boxes for a number stacked cost a row each and leave two thirds + # of the panel empty. Pairing them took the batch screen from 1259 px + # to 1173 on 2026-08-20. + # Since 2026-09-22 a name stands over its box and there is no column, + # so the way a declared setting leaves the edge is its whole block + # being indented under the fields above it. + "the block of declared settings is indented under the fields above it", + "internal/gui/parts/parts.go", + "\treturn Column(GapField, children...)", + "\treturn Indented(Column(GapField, children...))", + "TestADeclaredSettingStandsOnTheSameEdgeAsTheFieldsAboveIt", + ), + ( + "the quick comparison uses the directory as typed rather than its absolute spelling", + "internal/core/filename.go", + "filepath.Rel(b.abs,", + "filepath.Rel(b.named,", + "TestTheCheapComparisonUsesTheAbsoluteSpellingOfTheDirectory", + ), + ( + # Same class: right answers, cost back. Resolving the boundary inside + # the loop is what observation O117 measured at two thirds of a verify + # on Windows. The mutation keeps "boundary" in use so the package still + # compiles - a mutation that does not build proves nothing. + "the boundary is worked out again for every entry", + "internal/audit/audit.go", + " p, err := resolved(b, f)", + " p, err := resolved(core.NewBoundary(b.Dir()), f)", + "TestTheBoundaryIsWorkedOutOncePerPassAndNotOncePerEntry", + ), + ( + # The other direction, and it matters more here than usual: people keep + # fixtures on redirected paths, so refusing every link would break an + # ordinary setup. Two things had to be right for this to pass - the + # walk resolves its root, and the manifest claim survives Go's O_EXCL + # lying on a reparse point. + "the walk stops resolving the directory it was pointed at", + "internal/audit/walk.go", + "if resolved, err := filepath.EvalSymlinks(dir); err == nil {", + "if resolved, err := filepath.EvalSymlinks(dir); err != nil {", + "TestADirectoryReachedThroughALinkStillWorks", + ), + ( + # Found by audit on 2026-08-03. "--name trailing." finished with exit 0, + # the file landed as "trailing", and verify then reported the tool's own + # output as wrong. + "a name the system stores differently is accepted again", + "internal/engine/filename.go", + 'case strings.HasSuffix(name, ".") || strings.HasSuffix(name, " "):', + 'case (strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ")) && false:', + "TestANameTheSystemWouldNotStoreVerbatimIsRefused", + ), + ( + # The other direction. Refusing every name holding a dot would satisfy + # the guard above and refuse invoice.txt. + "the name rule spreads from the last character to the whole name", + "internal/engine/filename.go", + 'strings.HasSuffix(name, ".") ||', + 'strings.Contains(name, ".") ||', + "TestAnOrdinaryNameWithADotOrASpaceStillWorks", + ), + ( + "a manifest of any size is read into memory", + "internal/manifest/manifest.go", + "if int64(len(raw)) <= MaxBytes {", + "if len(raw) >= 0 {", + "TestAManifestTooLargeToReadIsRefusedBeforeItIsRead", + ), + ( + # Found by audit on 2026-08-03. Measuring an archive meant building it, + # and building it meant running every generator inside, two or three + # times over - so --dry-run on a 256 MB archive cost 960 ms against a + # 56 ms baseline, and one with large declared contents never returned. + # + # This mutation restores that path, so it is one of the slower entries + # here. It has to run to completion to be caught. + # + # Recorded honestly: the size guards go red on it as well, because + # generating the contents while also adding them arithmetically counts + # them twice. So this proves the cost guard runs, not that it is the + # only thing standing between us and the defect. What it alone would + # catch is a future change that keeps the sizes right and adds a second + # full pass - and no single swap expresses that. + "measuring an archive generates everything inside it again", + "internal/format/zip/zip.go", + "\t\tif withContents {\n\t\t\tif err := c.desc.Generator.Write(ctx, entry, c.plan); err != nil {", + "\t\tif true {\n\t\t\tif err := c.desc.Generator.Write(ctx, entry, c.plan); err != nil {", + "TestPlanningAContainerDoesNotGenerateWhatItHolds", + ), + ( + # Found by audit on 2026-08-03. Before the ceiling existed this reached + # make([]int64) and panicked with a Go stack trace under exit code 2. + "the flag path stops asking whether the count can be planned", + "internal/cli/generate.go", + "if int64(g.count) > core.MaxFilesPerRun {", + "if int64(g.count) > core.MaxFilesPerRun && false {", + "TestAnImpossibleFileCountIsRefusedRatherThanAllocated", + ), + ( + # The recipe reader took the worse of the two failures: it grew the list + # one entry at a time and reached a 13 GB allocation. + "the recipe reader stops asking whether the count can be planned", + "internal/recipe/target.go", + # Carries the line under it since 2026-08-20. The container entry gained + # the same ceiling that day, so this pattern stopped being unique - and + # an ambiguous pattern is how a mutation quietly starts proving a + # different guard than the one it names. The two are told apart by which + # address they build: `where` for the target, `at` for the entry. + "case n > core.MaxFilesPerRun:\n\t\t\tp.add(where.of(\"count\")", + "case n > core.MaxFilesPerRun && false:\n\t\t\tp.add(where.of(\"count\")", + "TestAnImpossibleFileCountInARecipeIsRefused", + ), + ( + # Expected verdict: CAPPED, for the same reason its sibling above is - + # the mutation removes the refusal, so the reader does the thing the + # refusal existed to prevent and allocates until the runner stops it. + # Measured on 2026-08-20 with try-one.py: stopped at 4.1 GB. Without the + # mutation the same recipe is refused in milliseconds, and that + # difference is the proof this entry carries. + "the container entry stops asking whether its count can be planned", + "internal/recipe/contains.go", + "case n > core.MaxFilesPerRun:\n\t\t\t\tp.add(at.of(\"count\")", + "case n > core.MaxFilesPerRun && false:\n\t\t\t\tp.add(at.of(\"count\")", + "TestAnImpossibleFileCountInsideAContainerIsRefused", + ), + ( + "the ceiling is applied per target, so writing the number out in pieces gets past it", + "internal/engine/engine.go", + "totalFiles += len(t.Sizes)", + "totalFiles = len(t.Sizes)", + "TestTheCeilingCountsTheWholeRunNotOneTarget", + ), + ( + # The other direction. A ceiling low enough to refuse ordinary work + # would satisfy every guard above and break the tool for everybody. + "the ceiling drops to something a real run would meet", + "internal/core/limits.go", + "const MaxFilesPerRun = 1_000_000", + "const MaxFilesPerRun = 10", + "TestAnOrdinaryFileCountStillWorks", + ), + ( + # This one never crashed, which is why it is the dangerous one: the sum + # wrapped negative, the free space check found the disk big enough, and + # the run started writing. + "the byte total may wrap again and satisfy the free space check", + "internal/core/limits.go", + "if total > math.MaxInt64-size {", + "if total > math.MaxInt64-size && false {", + "TestATotalThatDoesNotFitIsRefused", + ), + ( + "a boundary set with no room above the limit wraps instead of being refused", + "internal/core/size.go", + "if limit == math.MaxInt64 {", + "if limit == math.MaxInt64 && false {", + "TestABoundaryThatCannotHoldItsOwnSetIsRefused", + ), + ( + # Found by audit on 2026-08-03. Before the check existed, an entry with + # the path "../VICTIM.txt" and cleanup --yes --force removed a file + # above the output directory and ended with exit code 0. + "a manifest entry may climb out of the directory again", + "internal/core/filename.go", + 'strings.HasPrefix(cleaned, "../"):', + 'strings.HasPrefix(cleaned, "../") && false:', + "TestTheTextualContainmentCheckCatchesEveryWrittenClimb", + ), + ( + "the manifest is read without asking where its entries point", + "internal/manifest/manifest.go", + "if err := checkPaths(path, &m); err != nil {", + "if err := checkPaths(path, &m); err != nil && false {", + "TestLoadingAManifestRefusesAnEntryThatClimbsOut", + ), + ( + # Measured on Windows before the fix: an absolute path came out as + # "unreachable" because filepath.Join mangled it, which is the right + # outcome reached by accident and only while the volume differs. + "an absolute path in a manifest is resolved rather than refused", + "internal/core/filename.go", + "if hasVolumeName(unified) {", + "if hasVolumeName(unified) && false {", + "TestAManifestPathIsNeverAbsolute", + ), + ( + # The other direction. Refusing every path with a separator would + # satisfy the three guards above and quietly break grouped output, + # which nobody would notice until somebody used it. + "the containment check starts refusing paths that stay inside", + "internal/core/filename.go", + 'case cleaned == ".":', + 'case cleaned == ".", strings.Contains(cleaned, "/"):', + "TestAManifestPathMayStillNameASubdirectory", + ), + ( + # The other direction of the door in manifest.Load, so shutting it on + # everything is not a way to pass. + "the door refuses a manifest naming a file in a subdirectory", + "internal/manifest/manifest.go", + "problem := core.ContainmentProblem(f.Path)", + 'problem := core.ContainmentProblem("../" + f.Path)', + "TestLoadingAManifestAcceptsAnEntryInASubdirectory", + ), + ( + # And of the textual check itself. The prefix is emptied rather than + # shortened to a dot: none of the ordinary names in that guard begins + # with one, so shortening it refused nothing and proved nothing. + "the textual check starts refusing paths that stay inside", + "internal/core/filename.go", + 'case cleaned == "..", strings.HasPrefix(cleaned, "../"):', + 'case cleaned == "..", strings.HasPrefix(cleaned, ""):', + "TestTheTextualContainmentCheckLetsOrdinaryPathsThrough", + ), + ( + "a path before the flags stops being accepted", + "internal/cli/cli.go", + "case leading != \"\" && fs.NArg() == 0:", + "case false:", + "TestAPathIsAcceptedBeforeOrAfterTheFlags", + ), + ( + "a byte order mark is left in, so the first key reads as a typo", + "internal/recipe/canonical.go", + "for strings.HasPrefix(out, bom) {", + "for strings.HasPrefix(out, bom) && false {", + "TestARecipeCarryingAByteOrderMarkIsRead", + ), + ( + # The other direction. Trimming everywhere rather than at the front + # would eat a mark a recipe legitimately carries inside a value, which + # the guard checks with a mark in a file name. + "the mark is stripped wherever it appears, not just at the front", + "internal/recipe/canonical.go", + "out = strings.TrimPrefix(out, bom)", + 'out = strings.ReplaceAll(out, bom, "")', + "TestARecipeCarryingAByteOrderMarkIsRead", + ), + ( + # The regression this replaced: counting raw documents makes a document + # out of a comment before a leading "---", so one recipe reads as two + # and a file somebody had every reason to write is turned away. + "documents are counted raw again, so one recipe reads as two", + "internal/recipe/canonical.go", + "if d.Body == nil || d.Body.Type() == ast.CommentType {", + "if (d.Body == nil || d.Body.Type() == ast.CommentType) && false {", + "TestOneRecipeIsAcceptedWhateverSeparatorsSurroundIt", + ), + ( + # The other direction: counting nothing at all would accept two + # recipes and produce half the fixtures, which is what the rule exists + # to stop. One mutation per direction, because a counter can fail + # either way and the two are caught by different guards. + "every document is discounted, so two recipes slip through", + "internal/recipe/canonical.go", + "\t\tn++", + "\t\tn += 0", + "TestAFileHoldingTwoRecipesIsRefusedRatherThanHalfRun", + ), + ( + # The sharp one: the reader keeps its check, only the formatter loses + # it. That is the exact shape of the bug this replaced - fmt laid out a + # two recipe file and ended 0, and --check then called it clean. + "the formatter stops checking and drifts away from the reader", + "internal/recipe/canonical.go", + "f, err := oneDocument(withoutBOM(src), name)", + "f, err := parser.ParseBytes(withoutBOM(src), parser.ParseComments)", + "TestTheFormatterRefusesTheSameFileTheReaderRefuses", + ), + ( + "a boundary gives three identical sizes instead of consecutive ones", + "internal/core/size.go", + "return []int64{limit - 1, limit, limit + 1}, nil", + "return []int64{limit, limit, limit}, nil", + "TestABoundaryGivesThreeConsecutiveSizesAroundTheLimit", + ), + ( + "a boundary beside a size is accepted", + "internal/recipe/target.go", + "case rt.Boundary != nil && rt.Size != nil:", + "case rt.Boundary != nil && rt.Size != nil && false:", + "TestABoundaryBesideASizeOrACountIsRefused", + ), + ( + "a boundary set never reaches below the limit", + # The guard puts a boundary where WAV cannot deliver the lower edge - + # once below its minimum, once in the unreachable gap just above it. A + # set that quietly started at the limit would produce three valid files + # and the test would never learn that its lower edge was gone. + "internal/core/size.go", + "return []int64{limit - 1, limit, limit + 1}, nil", + "return []int64{limit, limit, limit + 1}, nil", + "TestABoundaryTheFormatCannotDeliverIsRefusedBeforeAnyFile", + ), + ( + "a name carrying a path is accepted, so files land outside the run", + "internal/engine/filename.go", + "case strings.ContainsAny(name, `/\\`):", + "case false:", + "TestANameThatIsAPathIsRefusedAndNothingEscapes", + ), + ( + "the manifest name is not checked, so a manifest lands elsewhere", + "internal/engine/engine.go", + 'if err := checkFileName(SettingOutputManifest, "the manifest", opt.ManifestName); err != nil {', + "if err := error(nil); err != nil {", + "TestAManifestNameThatIsAPathIsRefused", + ), + ( + "an unknown placeholder is left in the file name", + "internal/engine/engine.go", + 'if strings.Contains(name, "{") {', + "if false {", + "TestAnUnknownPlaceholderInANameIsRefused", + ), + ( + "the declared reason is dropped on the way to the manifest", + "internal/engine/engine.go", + "Reason: f.Target.ExpectedReason,", + 'Reason: "",', + "TestTheDeclaredReasonReachesTheManifest", + ), + ( + "a reason off the closed list is accepted", + "internal/recipe/target.go", + "} else if !reasons[s] {", + "} else if false {", + "TestAReasonOffTheClosedListIsRefused", + ), + ( + "recipe fmt --check passes whatever it is given", + "internal/cli/recipecmd.go", + "if *check {\n\t\tif settled {", + "if *check {\n\t\tif true {", + "TestFormattingCommandSettlesAndLeavesASettledFileAlone", + ), + ( + "the canonical form gains a stray blank line", + "internal/recipe/canonical.go", + "out := []byte(f.String())", + 'out := []byte(f.String() + "\\n")', + "TestTheCanonicalFormOfARecipeHasNotDrifted", + ), + # The three below all leave the size exact and the run at code 0, which is + # the point. Every one of them shipped in a file the owner looked at and + # accepted, and no guard in the suite had anything to say. + ( + "the record that did not fit keeps its number", + "internal/core/records.go", + "rec.Discard()", + "_ = rec", + "TestRecordNumbersRunFromOneWithoutAGap", + ), + ( + "the closing label shares a baseline with the identity label", + "internal/format/svgfile/svg.go", + "``, h-34", + "``, h-16", + "TestNothingIsPaintedOverTheSVGLabel", + ), + ( + "shapes reach the bottom edge and cover the label", + "internal/format/svgfile/svg.go", + "\tif d := h - TextBand; d > 0 {", + "\tif d := h - 0; d > 0 {", + "TestNothingIsPaintedOverTheSVGLabel", + ), + # Removed 2026-08-02: "the vocabulary stops being ASCII and the filler cuts + # a character in half". It was caught before core.AppendFiller learned to + # cut on a character boundary, and it is NOT CAUGHT after - which is the + # runner proving the fix rather than a hole. Breaking the guard now needs + # two changes at once, a non ASCII vocabulary and a byte cut, and that is + # not one substitution. It moved to provenByProbe with both measurements. + ( + # Set takes a color.Color interface, so every pixel boxes a colour onto + # the heap. This is what the code did until 2026-08-02: 786443 objects + # for one 16 MiB image, while the byte budget beside it stayed green + # because the boxes are small and short lived. + "a colour is boxed onto the heap for every pixel", + "internal/format/png/png.go", + "img.SetRGBA(x, y, color.RGBA{", + "img.Set(x, y, color.RGBA{", + "TestNoGeneratorHoldsTheWholeFileInMemory", + ), + ( + "a goroutine appears outside the files that declare concurrency", + "internal/core/records.go", + "shortest := rec.Shortest()", + "shortest := rec.Shortest()\n\tgo func() {}()", + "TestConcurrencyStaysWhereItWasPutOnPurpose", + ), + ( + # Rule 13 asks for one dash and no semicolons in the prose that ships. + # Nothing held it until 2026-08-02, and the tree passed on the day the + # guard arrived - which is the state a ratchet wants to start from. + "a semicolon reaches the prose the user reads", + "internal/cli/cli.go", + "// Exit codes are a frozen contract.", + "// Exit codes are a frozen contract; changing one is breaking.", + "TestProseInTheRepositoryUsesFlatHyphensAndNoSemicolons", + ), + ( + # Deliberately outside internal/cli and cmd/tfg. The ASCII guard covers + # the dash there already, so a mutation in those two would re-prove + # ground that was held, not the ground this guard added. + "an em dash reaches a comment the ASCII guard never looks at", + "internal/core/records.go", + "// A truncated last record is the worst kind of defect this tool can ship.", + "// A truncated last record — the worst defect this tool can ship.", + "TestProseInTheRepositoryUsesFlatHyphensAndNoSemicolons", + ), + ( + # Base 0 asks Go to infer the base from the prefix, which is exactly the + # rule YAML applied and exactly the defect this type was written to + # remove: 010 becomes 8 again. Sizes stay right and the exit code stays + # zero, so nothing but the fidelity guard notices. + "a padded number is read in the base its prefix suggests", + "internal/recipe/scalar.go", + "n, err := strconv.ParseInt(s.text, 10, 64)", + "n, err := strconv.ParseInt(s.text, 0, 64)", + "TestANumberInARecipeMeansWhatItLooksLike", + ), + ( + # Guessing instead of refusing. A spelling we cannot read becomes zero + # and the run goes ahead, which is the same silence in the other + # direction - seed 0x10 would quietly become seed 0. + "a spelling we cannot read is guessed at instead of refused", + "internal/recipe/scalar.go", + "if err != nil {", + "if err != nil && false {", + "TestASpellingThatOnlyYAMLCallsANumberIsRefused", + ), + ( + # Every file of a target then draws the same size. That still hits the size + # to the byte and still repeats across runs, so the exact size guard and + # the determinism guard both stay green while the feature is useless. + "every file of a range draws the same size", + "internal/engine/drawsizes.go", + "r := core.NewRand(core.SizeSeed(targetSeed, i))", + "r := core.NewRand(core.SizeSeed(targetSeed, 0))", + "TestARangeIsRepeatableAndStaysInsideItsEnds", + ), + ( + # Sizes that depend on how many files were asked for. This is rule 2 + # broken, and it stays invisible until somebody raises a count and every + # hash below it moves for no reason they can see. + "the size of a file depends on how many files were asked for", + "internal/engine/drawsizes.go", + "r := core.NewRand(core.SizeSeed(targetSeed, i))", + "r := core.NewRand(core.SizeSeed(targetSeed, i+len(t.Sizes)))", + "TestRaisingTheCountOfARangeLeavesTheEarlierFilesAlone", + ), + ( + # Judged at the wrong end of the range. A range whose low end the format + # cannot deliver then passes planning, and whether the run works at all + # depends on what the seed happened to draw. + "a range is judged at its high end instead of its low end", + "internal/engine/drawsizes.go", + "\t\tfirst.Bytes = t.Range.Min", + "\t\tfirst.Bytes = t.Range.Max", + "TestARangeIsJudgedByItsLowEndRatherThanByWhatWasDrawn", + ), + ( + # Two ways of stating a size then stop being an error and one of them wins + # in silence. The recipe still produces files of an exact size, so only a + # guard that asks about the pairing itself notices. + "two size declarations in one target stop being an error", + "internal/recipe/target.go", + "case rt.SizeRange != nil && rt.Size != nil:", + "case rt.SizeRange != nil && rt.Size != nil && false:", + "TestTwoSizeDeclarationsInOneTargetAreRefused", + ), + ( + # Every archive of the target comes out at the low end of its range. The + # sizes are still exact, still inside the range and still repeatable, so + # the only thing that gives it away is that nothing varies. + "a container range collapses onto its low end", + "internal/engine/drawsizes.go", + "span := uint64(t.Range.Max - t.Range.Min)", + "span := uint64(0)", + "TestAContainerTakesARangeAndStillHoldsItsContents", + ), + ( + # The flag stops drawing and gives every file the low end instead. Both + # surfaces still work on their own, and they quietly disagree - which is + # the failure that makes a copied command line untrustworthy. + "the range flag stops meaning what the range key means", + "internal/cli/generate.go", + "lo, hi, err := core.ParseSizeRange(g.sizeRange)", + "hi, lo, err := core.ParseSizeRange(g.sizeRange)", + "TestTheRangeFlagAndTheRangeKeyAgree", + ), + ( + # Three files at the limit rather than one either side of it. The count is + # right, the sizes are exact and the whole point of a boundary set is + # gone. + "a boundary set from the flag is three files at the limit", + "internal/cli/generate.go", + "sizes, err := core.BoundarySizes(limit)", + "sizes, err := core.BoundarySizes(limit + 1)", + "TestTheBoundaryFlagGivesTheThreeSizesAroundTheLimit", + ), + + # TAR.GZ. Its size comes from arithmetic rather than from measuring the + # structure the way every other format here does, so most of these break the + # arithmetic in a different place. Each one leaves the tree compiling and + # the file looking ordinary. + ( + # The stream closes with an empty stored block that costs five bytes. + # Drop it from the sum and every archive is five bytes longer than the + # plan said. + "the closing stored block is counted twice", + "internal/format/targz/size.go", + "return f.base + f.perBlock*blocks + tarLen", + "return f.base + f.perBlock*(blocks+1) + tarLen", + "TestTarGzSizeArithmeticSurvivesEveryShape", + ), + ( + # Tar pads every entry up to a whole 512 byte block. Stop rounding and + # the arithmetic is right only for entries that happen to be a multiple + # of 512, which the default shape is. + "the tar block padding stops being counted", + "internal/format/targz/size.go", + "return n + tarBlock - r", + "return n", + "TestTarGzSizeArithmeticSurvivesEveryShape", + ), + ( + # The comment costs its length plus the byte that ends it. + # + # It moved on 2026-09-01, and where it moved FROM is the interesting + # half. Until that day padding rode in the comment, so this off by one + # was absorbed: the solve built a comment one byte longer and the + # archive still came out exact, leaving only the floor to shift - which + # is what the minimum guard watches, and it was aimed there for that + # reason, measured 2026-08-04. + # + # Padding rides in the extra field now and the comment carries the + # label alone, so nothing absorbs it and the byte lands on the size. + # The full run called this a HOLE, correctly: the named guard stayed + # green. Measured by hand the same day - three guards go red on it now, + # and the arithmetic guard is the one that owns the fact. + "the comment terminator stops being counted", + "internal/format/targz/size.go", + "return int64(len(comment)) + 1", + "return int64(len(comment))", + "TestTarGzSizeArithmeticSurvivesEveryShape", + ), + ( + # Compression makes the output size unknowable without compressing, so + # the plan and the file part company. This is the decision the format + # rests on, mutated. + "the archive starts compressing, so its planned size stops being real", + "internal/format/targz/size.go", + "gzip.NewWriterLevel(w, m.squeeze.Level)", + "gzip.NewWriterLevel(w, gzip.BestSpeed)", + "TestTarGzSizeArithmeticSurvivesEveryShape", + ), + ( + # One byte above the floor is unreachable without a label, because Go + # will not emit a zero length comment. Accept it and a file comes out a + # byte short of what was ordered. + "the size one byte above the floor stops being refused", + "internal/format/targz/size.go", + "\t\tcomment, withExtra, extra, ok := place(label, want)\n\t\tif !ok {", + "\t\tcomment, withExtra, extra, ok := place(label, want)\n\t\tif !ok && false {", + "TestTarGzHasExactlyOneGapAboveItsFloor", + ), + ( + # Padding never leaves the comment, so a 300 kB archive carries a 300 kB + # gzip comment. The size is still exact and 7-Zip refuses the whole file + # - measured 2026-08-04, the ceiling is 65 535 B. This is the mutation + # that has to be caught by a reference tool rather than by a size. + "padding above the comment limit stays in the comment", + "internal/format/targz/size.go", + "if want <= maxCost() {", + "if want <= maxCost() || true {", + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + # Drop a format from the structural checker and the reference tool test + # simply skips that layer, in silence. That is why the list is declared + # and compared rather than trusted. + "a format loses its structural check without anything saying so", + "internal/oracle/oracle.go", + 'case "png", "wav", "pdf", "zip", "targz", "log", "csv", "json", "xml", "svg", "html",', + 'case "png", "wav", "pdf", "zip", "log", "csv", "json", "xml", "svg", "html",', + "TestTheStructuralCheckerCoversEveryFormatItShould", + ), + ( + # When contains decides the size, the label states a measurement of the + # archive the label is inside, so the two settle against each other. + # Stop at the first round and the size is whatever the first guess was. + # + # Aimed first at the starting value of that loop, by adding one to it, + # and that stayed GREEN - measured 2026-08-04. The loop converges from + # any starting point, so it corrects the mutation. Worth knowing about + # the design rather than about the guard. + "an archive sized by its contents stops settling with its own label", + "internal/format/targz/targz.go", + "if measured == size {", + "if measured == size || true {", + "TestATarGzDeclaringItsContentsNeedsNoSizeAndTheDryRunIsExact", + ), + ( + # Members are numbered per format so two groups of the same format do + # not both start at 0001. Freeze the number and they collide inside the + # archive - tar takes two entries under one name without complaint, so + # nothing but a name check notices. + "two members of an archive are written under one name", + "internal/format/targz/targz.go", + 'name: prefix + fmt.Sprintf("%s_%04d%s", g.Format, numbered[g.Format], desc.Extension),', + 'name: prefix + fmt.Sprintf("%s_%04d%s", g.Format, 1, desc.Extension),', + "TestTheMembersOfATarGzAreRealFilesOfEveryFormatAsked", + ), + ( + # A format losing the only case that pins its bytes. Until 2026-08-04 + # nothing noticed: the neighbouring check compares how many cases there + # are against how many golden values there are, and both counts stay + # right when a case changes which format it produces. + # + # Aimed at a format with ONE case. This aimed at md until 2026-09-16, + # and md had grown two more cases (its encodings, 2026-09-07), so + # turning one of them into txt left md pinned by the other two and + # the guard green - a mutation of one of two layers moves nothing. + # pdf has one case, measured by counting Format: fields in the file. + "a format quietly stops having its bytes pinned anywhere", + "internal/guard/generatorbytes_test.go", + '"pdf_16kib": {ID: "g", Format: "pdf", Sizes: engine.Uniform(1, 16384), Label: true},', + '"pdf_16kib": {ID: "g", Format: "txt", Sizes: engine.Uniform(1, 16384), Label: true},', + "TestEveryFormatHasAGoldenValue", + ), + ( + # The checklist writes format names the way a person does, so TAR.GZ has + # to meet the id targz. Stop folding the dot away and the one format + # whose name carries one looks missing - which is the half of this guard + # that does the work, since every other name matches already. + "the fidelity checklist stops recognising a name with a dot in it", + "internal/guard/registry_test.go", + 'listed[strings.ToLower(strings.ReplaceAll(strings.TrimSpace(m[1]), ".", ""))] = true', + "listed[strings.ToLower(strings.TrimSpace(m[1]))] = true", + "TestEveryFormatHasARowInTheFidelityChecklist", + ), + ( + # Reading the wrong part of the document. The format file is full of + # tables whose first cell is a bold name, so a guard pointed at the + # wrong section would happily find rows and check nothing. + "the fidelity checklist is looked for in the wrong section", + "internal/guard/registry_test.go", + 'start := strings.Index(text, "## 6.1")', + 'start := strings.Index(text, "## 7.")', + "TestEveryFormatHasARowInTheFidelityChecklist", + ), + ( + # The real defect, put back. The minimum table called itself indicative + # and nothing read it, so it said images start at 30 to 70 B while PNG + # refused anything under 73 - outside its own stated range, with a green + # suite. Wrong number in the table is the first of the two shapes. + "the minimum table states a number the registry does not refuse at", + "docs/MVP-FORMATS.md", + "| **PNG** | **74 B**", + "| **PNG** | **70 B**", + "TestTheMinimumTableCarriesTheMeasuredNumberForEveryBuiltFormat", + ), + ( + # And the second shape, which is how the table drifted in the first + # place: a built format has no row of its own and hides inside a + # grouped range. ZIP shared a cell with 7Z and a span of 22 to 35 B + # while the registry refused under 156. + "a built format loses its own row in the minimum table", + "docs/MVP-FORMATS.md", + "| Archiwa | **ZIP** | **156 B**", + "| Archiwa | ZIP · 7Z | 22-35 B |\n| | unused | **156 B**", + "TestTheMinimumTableCarriesTheMeasuredNumberForEveryBuiltFormat", + ), + ( + # The real defect, put back. ZIP declared entry_size as 8kb and used + # 4096, so tfg formats printed one answer and generating without the + # setting gave the other. Both numbers were written by hand in two + # places and nothing compared them - the declaration is only read for + # printing. This is that drift, reintroduced by hand. + "a container goes back to ignoring the default it advertises", + "internal/format/archive/archive.go", + "sizeProperty(id, r.Properties, EntrySize, defaultSize)", + "sizeProperty(id, r.Properties, EntrySize, 4096)", + "TestADeclaredDefaultIsTheOneTheFormatUses", + ), + ( + # The other half: a guard that compares nothing passes. Skipping every + # declared default leaves the loop with no work, and without the count + # at the end this would be a green test that looked at zero formats. + "the default check skips every property it was written for", + "internal/guard/declareddefaults_test.go", + 'if p.Default == "" {', + 'if p.Default == "" || true {', + "TestADeclaredDefaultIsTheOneTheFormatUses", + ), + ( + # The class a file belongs to stops reaching the manifest. Nothing + # about the file changes, so every other guard stays green and the + # assertion somebody wanted to write about a whole class has nothing + # to stand on. + "the group of a file never arrives in the manifest", + "internal/engine/engine.go", + "Group: f.Target.Group,", + 'Group: "",', + "TestAGroupInTheRecipeReachesTheManifest", + ), + ( + # Written out as an empty string rather than left out. A consumer then + # cannot tell "no class" from "a class called nothing", and the + # difference only shows in the raw bytes somebody's script reads. + "a file with no group says it has one called nothing", + "internal/manifest/manifest.go", + 'Group string `json:"group,omitempty"`', + 'Group string `json:"group"`', + "TestAGroupInTheRecipeReachesTheManifest", + ), + ( + # The drift that was found on 2026-08-04, put back. validate used to + # build its own engine targets and left this out, so it planned the + # numbered names for a boundary set instead of under_1b, at_limit + # and over_1b - and refused recipes generate accepts. + "validate goes back to imagining the wrong names for a boundary set", + "internal/cli/generate.go", + "BoundaryLimit: t.BoundaryLimit,", + "BoundaryLimit: 0,", + "TestValidateAgreesWithGenerateAboutBoundaryNames", + ), + ( + # The other half of that pair. Teaching validate to stop imagining + # names must not turn into teaching it to accept everything, so the + # reverse case needs its own break: the collision check itself, seen + # from the command that exists to run it before a commit. + "two targets heading for one name stop being a collision", + "internal/engine/names.go", + "if !clash {", + "if !clash || true {", + "TestValidateStillCatchesARealBoundaryCollision", + ), + ( + # A preset expanding into something this build will not read. It is + # the same bytes that eject prints and a run consumes, so a preset + # that produces an unreadable recipe is one nobody can run and nobody + # can edit either. + "a preset expands into a recipe the parser refuses", + "internal/preset/limitset.go", + "\t\t\tFormat: s.desc.ID,\n", + '\t\t\tFormat: s.desc.ID + "x",\n', + "TestEveryPresetExpandsIntoARecipeThisBuildAccepts", + ), + ( + # The flip moved off the limit. Seven files still appear, all of them + # the right size, and the one statement the preset exists to make is + # wrong - the file ON the limit is the one people argue about. + "the file on the limit is expected to be rejected", + "internal/preset/limitset.go", + "accept: true})", + "accept: false})", + "TestSizeBoundariesPutsTheFlipExactlyAtTheLimit", + ), + ( + # PR7 and the rule against silence. A limit too small for the files + # below it produces part of a set instead of a refusal, and the part + # that fits is never the part the run was about. + "a set that cannot be completed is delivered in part", + "internal/preset/limitset.go", + "if one.size >= floor {", + "if one.size >= floor || true {", + "TestSizeBoundariesRefusesASetItCannotCompleteRatherThanPartOfIt", + ), + ( + # The budget somebody plans disk space around. A figure typed beside the + # code was wrong by a factor of three and a half for three days, so this + # one is counted from the plan - and an off by one in the counting puts + # it right back to being a number nothing checks. + "the budget counts all but one of the files it would write", + "internal/cli/preset.go", + "Bytes: engine.TotalBytes(planned), Formats: formats,", + "Bytes: engine.TotalBytes(planned[:len(planned)-1]), Formats: formats,", + "TestTheBudgetShownIsTheBudgetWritten", + ), + ( + # PR5, in the form that cannot be a promise: eject and a run consume the + # same bytes. Expanding on the declared defaults instead of what the + # caller gave makes eject print a recipe for a different set than the one + # the same command line would produce. + "eject expands on the defaults rather than on what was given", + "internal/cli/presetcmd.go", + "expanded, err := preset.Expand(id, givenPresetArgs(fs, p))", + "expanded, err := preset.Expand(id, nil)", + "TestEjectingAPresetAndRunningItGivesTheSameRunBackDefaultsIncluded", + ), + ( + # Untouchable rule 5. A limit we invented and a limit somebody measured + # produce files that look identical and mean opposite things, and the + # manifest is the only place that outlives the sentence saying which. + "the manifest stops saying which numbers were ours", + "internal/cli/preset.go", + "Defaulted: e.Defaulted,", + "Defaulted: nil,", + "TestTheManifestSaysWhichNumbersWereOursRatherThanTheCallers", + ), + ( + # "tfg preset eject x > my.yaml" has to give a file that is a recipe. + # Prose on the data channel puts a sentence about our placeholder limit + # inside a file somebody is about to commit. + "the note about an invented limit lands inside the ejected recipe", + "internal/cli/presetcmd.go", + # Anchored on the write that follows, because the run path prints the + # same note and the pattern alone would match both. + "sayNotes(expanded.Notes(), errOut)\n\tif to.set {", + "sayNotes(expanded.Notes(), out)\n\tif to.set {", + "TestEjectPutsTheRecipeOnStandardOutputAndTheRestBesideIt", + ), + ( + # CLI.md section 6: a parameter clashing with a global flag is a mistake + # in the preset, caught at startup. The flag package answers a name + # registered twice with a panic, so this one has to be caught before it + # ships rather than reported by somebody hitting it. + "a preset declares a parameter that is already a global flag", + "internal/preset/sizeboundaries.go", + 'Name: "spread", Kind: format.PropertyText,', + 'Name: "seed", Kind: format.PropertyText,', + "TestNoPresetDeclaresAParameterThatIsAlreadyAFlag", + ), + ( + # A preset id nobody registered is a typo in the invocation. Without its + # own line it falls through to RUNTIME, which tells CI this program has a + # bug rather than that somebody mistyped a name. + "a preset nobody registered is reported as a fault of the tool", + "internal/cli/errors.go", + "var unknownPreset *preset.UnknownPresetError", + "var unknownPreset *preset.ImpossibleError", + "TestEveryPresetEndingHasTheCodeItShould", + ), + ( + # The message listing the ways to say what to produce named two of them + # for as long as there were two. A preset is the third, and it is the one + # somebody reaching for this tool first would want. + "the message about producing nothing goes back to naming two ways out", + "internal/cli/generate.go", + "name a recipe file, or pick a preset with --preset.", + "or name a recipe file.", + "TestNothingToProduceNamesAllThreeWaysOut", + ), + ( + # Which flags exist depends on which preset was named, so the scan that + # finds it has to handle every spelling the flag package accepts. Missing + # one turns a documented invocation into "flag provided but not defined". + "the preset written with an equals sign is not found before parsing", + "internal/cli/preset.go", + "\t\tif hasValue {\n\t\t\treturn value\n\t\t}", + "\t\tif hasValue && false {\n\t\t\treturn value\n\t\t}", + "TestReadingThePresetOutOfTheArgumentsHandlesBothSpellings", + ), + ( + # A parameter that parses and never reaches the plan is the worst shape + # of this: the run succeeds, the files are the right size, and the set is + # built around a number nobody asked for. + "a parameter the caller gave never reaches the expansion", + "internal/cli/preset.go", + 'if names[f.Name] && f.Value.String() != "" {', + 'if names[f.Name] && f.Value.String() != "" && false {', + "TestAPresetParameterBecomesAFlagAndAnUnknownOneDoesNot", + ), + ( + # The window renders and shows nothing - a screen built from parts that + # draw no content. It passes every structural check, exactly as SVG did + # at its minimum size, which is why both are guarded by counting + # colours rather than by parsing. + # Aimed at the composition rather than at one label, and that is a + # measurement rather than a preference. Emptying a single label leaves + # the colour count at 188, identical to the full screen, because the + # number counts the anti-aliasing palette of the font rather than the + # amount of text. Dropping every section takes it to 1. + "every screen renders as an empty rectangle", + "internal/gui/window/open.go", + # Moved here on 2026-08-11, and the move is worth the note. It emptied + # parts.Screen until then, which stopped proving anything the moment the + # actions were pinned outside the scrolling part: an empty form still + # draws a bar with three buttons on it, so the guard stayed green on a + # screen with no form at all. The runner reported that the same day. + # + # Emptying what the window is handed is the question the guard actually + # asks - does anything get drawn - and it survives the layout moving + # again, because it does not name a layout. + "return NewGenerate(h).Object()", + "return container.NewVBox()", + "TestTheWindowActuallyDrawsSomething", + ), + ( + # The sentence somebody using only the window has no other way to read: + # the licence covers the tool and not the files it makes. It reached + # the terminal from 2026-08-04 and reached the window from today. + "the licence screen stops saying who owns the generated files", + "internal/version/version.go", + "The files you generate are yours.", + "The files you generate are covered too.", + "TestTheAboutScreenSaysWhoOwnsTheGeneratedFiles", + ), + ( + # Two surfaces, one constant. They sit on the same layer and cannot + # import each other, which is the shape in which a second copy gets + # written and nobody compares the two again. + "the window quotes its own copy of the licence instead of the shared one", + "internal/gui/window/about.go", + # Repointed 2026-08-11, when the notice moved into a card like every + # other block on every other screen. + # And again 2026-09-23, when the window joined the notice's lines into + # paragraphs so it wraps to the window. + "parts.Section(text.SectionLicence(), parts.Prose(paragraphs(version.LicenceNotice))),", + 'parts.Section(text.SectionLicence(), parts.Prose("Released under the GNU General Public License, version 3.")),', + "TestTheWindowAndTheCommandQuoteTheSameLicence", + ), + ( + # D16 and untouchable rule 8. The toolkit brings net/http and the + # command line must not inherit it - a build for a server carrying a + # socket cannot demonstrate the promise to anybody auditing it. + # Breaks the subject rather than the judge. Shortening the guard's own + # list of network packages changes nothing while the binary links none + # of them - it proves the list, not the binary. This puts a socket in + # the command line, which is the thing that must never happen. + "the command line binary gains a socket, as it would from one careless import", + "cmd/tfg/main.go", + '\t"context"\n\t"os"', + '\t"context"\n\t_ "net/http"\n\t"os"', + "TestTheCommandLineBinaryCannotReachTheNetwork", + ), + ( + # Same shape one layer up: the toolkit's own imports are outside our + # control and written down as such, ours are not. A window that fetches + # something itself is the case this watches, and it would be invisible + # to the scan beside it, which excludes the whole gui tree. + "our own window code starts reaching for the network", + "internal/gui/window/about.go", + '\t"fyne.io/fyne/v2"\n', + '\t"fyne.io/fyne/v2"\n\t_ "net/http"\n', + "TestOurOwnWindowCodeDoesNotReachTheNetwork", + ), + ( + # The first window that does work rather than only saying what the tool + # is. Everything below breaks the subject rather than the judge: the + # guards read the widget tree a person is shown, so shortening a list + # inside a guard would leave the window offering exactly what it offers + # and prove nothing. + # G6, and the one thing a window does better than a command line rather + # than merely as well. --dry-run has to be known about and remembered. + "Generate comes before Preview, so the cost arrives after the writing", + "internal/gui/window/runactions.go", + "r.previewBtn, r.generateBtn,", + "r.generateBtn, r.previewBtn,", + "TestPreviewComesBeforeGenerate", + ), + ( + # A preview that writes is not a preview. The whole value of the button + # is that the answer costs nothing, on a machine where a preset can run + # to several gigabytes. + "the preview writes the files it was only supposed to count", + "internal/gui/window/run.go", + "\topt.DryRun = true\n", + "\topt.DryRun = false\n", + "TestPreviewSaysTheCostAndWritesNothing", + ), + ( + # The menu is filled from the registry so a fourteenth format joins it + # on the day it is registered. Dropping one leaves twelve formats a + # person can pick and thirteen the engine has, which is D1 breaking + # quietly - the window simply offers less. + "the window offers fewer formats than the registry holds", + "internal/gui/window/generate.go", + "\tids := format.IDs()", + "\tids := format.IDs()[1:]", + "TestTheWindowOffersEveryFormatTheRegistryHas", + ), + ( + # What a setting takes is the sentence "tfg formats" prints, read from + # the declaration. Losing it leaves a box with a name over it and no way + # to know a page count stops at five thousand. + "a generated field stops saying what the setting takes", + "internal/gui/parts/property.go", + "\tdetail := allowedOnAScreen(p)", + '\tdetail := ""', + "TestTheWindowDrawsAFieldForEveryDeclaredProperty", + ), + ( + # G1. A window that words a refusal itself is a second copy of the + # rules, and the copy is the one that drifts - after which the window + # accepts or refuses something the command line does not, and nobody + # compares the two. + "the window words a size refusal itself instead of asking the engine", + "internal/gui/window/generate.go", + "\tbytesWanted, err := core.ParseSize(g.size.Text)\n\tif err != nil {\n\t\tbad = append(bad, saying(format.SettingSize, err))\n\t}", + # errors.New rather than fmt.Errorf, because this file does not import + # fmt and the mutation reported BROKEN for two runs - a mutation that + # does not compile proves nothing about the guard it names. + '\tbytesWanted, err := core.ParseSize(g.size.Text)\n\tif err != nil {\n\t\tbad = append(bad, errors.New("that is not a size this window accepts"))\n\t}', + "TestTheWindowAsksTheEngineWhetherASizeIsGood", + ), + ( + # G9 as a property of what arrives rather than of the wording. A + # refusal in this tool has four parts and an area that shortens it + # delivers one of them, which is the failure mode the rule was written + # against. + "the error area shortens the refusal it was given", + "internal/gui/parts/field.go", + "\ta.label.SetText(text)", + "\ta.label.SetText(text[:min(len(text), 40)])", + "TestTheWindowShowsTheWholeRefusal", + ), + ( + # A box that is typed into and dropped on the way to the engine looks + # exactly like one that works, and the parity list would say the window + # can set a format property while the window sets nothing. + "the generated property fields stop reaching the run", + "internal/gui/window/generate.go", + "\t\tProperties: g.properties(),", + "\t\tProperties: nil,", + "TestWhatIsTypedOnTheScreenIsWhatGetsWritten", + ), + ( + # A surface that writes files and forgets the record leaves them with + # nothing able to remove them. The command line learned this on + # 2026-08-03 and the window inherits the rule rather than the memory. + "the window writes the files and never records them", + "internal/gui/window/runreport.go", + "\tif opt.DryRun || res == nil || !res.Started {", + "\tif opt.DryRun || res == nil || res.Started {", + "TestGeneratingFromTheWindowWritesTheFilesAndTheManifest", + ), + ( + # G7, and the half that is easy to leave out. Cancelling and closing at + # once ends the process while the engine is still winding down, so the + # manifest describing what did get written never lands - and that + # manifest is the only thing cleanup can work from. Measured on + # 2026-08-05: red three times out of three, with no manifest at all. + "closing the window cancels the run without waiting for it", + "internal/gui/window/run.go", + "\t\tcancel()\n\t\t<-done\n\t}\n\n\tgo func() {\n\t\tplanned, planErr := engine.PlanContext(ctx, targets, opt)\n\t\tif planErr != nil {\n\t\t\t// A refusal", + "\t\tcancel()\n\t\t_ = done\n\t}\n\n\tgo func() {\n\t\tplanned, planErr := engine.PlanContext(ctx, targets, opt)\n\t\tif planErr != nil {\n\t\t\t// A refusal", + "TestClosingTheWindowDuringARunStopsItAndWaitsForIt", + ), + ( + # Found by running the built window and looking at it, on 2026-08-05. + # The format menu was empty while every guard was green, because this + # test package pulls the registrations in for its own use - so the + # registry was full everywhere a test could see and empty in the binary + # somebody would be handed. No test that imports the code can catch + # that, which is why the guard asks the compiler instead. + "the window binary stops carrying the format registrations", + "internal/gui/window/generate.go", + '\t_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"\n', + "", + "TestBothBinariesCarryTheFormatRegistrations", + ), + ( + # The preset menu is the registry's, so a second preset joins it on the + # day it is registered. This is the drift O59 already caught once, from + # the other side: a preset was added and the parity guard stayed green + # because nothing looked for one. + "the window offers fewer presets than the registry holds", + "internal/gui/window/preset.go", + "\tids := preset.IDs()", + "\tids := preset.IDs()[:0]", + "TestTheWindowOffersEveryPresetThereIs", + ), + ( + # The question is the whole reason a preset is not a recipe with a name. + # A list of ids says nothing about which one answers what somebody came + # to ask, and the title is the id spelled nicely. + "the preset screen shows the name instead of the question it closes", + "internal/gui/window/preset.go", + "p.about.Add(parts.Prose(text.PresetQuestion(chosen.ID, chosen.Question)))", + "p.about.Add(parts.Prose(text.PresetQuestion(chosen.ID, chosen.Title)))", + "TestTheWindowDrawsAFieldForEveryPresetParameter", + ), + ( + # Turning a parsed recipe into engine targets is written twice, because + # the two surfaces cannot share it. Exactly this conversion drifted + # before: validate and generate each had one and validate had lost + # BoundaryLimit. The guard does not compare the two lists of fields - it + # runs one preset from both surfaces and compares the bytes. + "the window drops one field on the way from recipe to engine", + "internal/gui/window/preset.go", + "\t\tLabel: t.Label,", + "\t\tLabel: false,", + "TestThePresetScreenAndTheCommandLineProduceTheSameRun", + ), + ( + # Untouchable rule 5. A limit belongs to somebody else's system, and a + # set built around a number we invented carries expectations that read + # exactly like a set built around the real one. The run says so out loud + # or it is lying by omission. + "the window stops saying which number it invented", + "internal/gui/window/preset.go", + "\tp.notes = notes\n", + "\tp.notes = notes[:0]\n", + "TestThePresetScreenSaysWhichNumbersWereOurs", + ), + ( + # A dot is legible in a terminal, where you typed your way into the + # directory, and opaque in a window started from a desktop. This is the + # one part of this tool that writes into other people's directories. + # + # It mutates the PRESET screen although the guard reads both, and that + # is measured rather than chosen. Open ends with showGenerate(), which + # carries pre.OutDir() into the generate screen, so the generate + # screen's own starting value is overwritten before anything can look + # at it - mutating it there changed nothing and the guard stayed green + # through a run that reported it as a hole. The carry arrived with the + # window telling one answer about where the files go, so a real change + # quietly emptied a mutation that still matched its pattern and still + # compiled. Staleness cannot see this: the text is all still there. + # And it moved BACK on 2026-08-12, for the same reason in the other + # direction. Selecting the preset tab carries the generate screen's + # answer over - open.go, OnSelected - so the preset screen's own + # starting value is overwritten before the guard reads it, and this + # entry reported NOT CAUGHT while the behaviour was perfectly intact. + # Twice now the same mutation has been emptied by a change somewhere + # else, both times invisible to staleness because the text still + # matches, and both times found only by running it. + # + # The generate screen is where it means something: its value is the one + # that gets carried, so a dot there is a dot on both screens. + "the window goes back to offering a dot as the output directory", + "internal/gui/window/generate.go", + "\tg.outDir = entry(startingDirectory(), \"\")", + "\tg.outDir = entry(\".\", \"\")", + "TestBothScreensSayWhereTheFilesWillGo", + ), + ( + # "text" is a word where a description should be. Seen on screen as + # "text, default 1B,1kb,1mb" for a setting that wants a list of sizes - + # the declaration knew that and had nowhere to put it. + "a text setting goes back to describing itself as text", + "internal/format/format.go", + "\t\twhat = p.Shape", + '\t\twhat = "text"', + "TestNoTextSettingDescribesItselfAsText", + ), + ( + # The entries have commas inside them, so run together they read as + # three times as many findings as there are. + "what a preset finds is run together into one sentence", + "internal/gui/window/preset.go", + # One label instead of a list. Written without strings.Join on purpose: + # preset.go does not import strings, and a mutation that does not build + # is reported as BROKEN and proves nothing at all. + "\t\tp.about.Add(parts.Bullets(text.PresetCatches(chosen.ID, chosen.Catches)))", + "\t\tp.about.Add(parts.Note(chosen.Catches[0]))", + "TestWhatAPresetFindsIsShownAsSeparateLines", + ), + ( + # The real defect, put back: a word reaching a person from outside the + # package that holds the words. This is the state the whole window was + # in until 2026-08-10, when about forty labels and messages sat as + # literals across six files and nobody could count them. + "a label goes back to being a literal in the window", + "internal/gui/window/open.go", + '&parts.Tab{ID: tabPresets, Text: text.TabPresets(), Content: pre.Object()},', + '&parts.Tab{ID: tabPresets, Text: "Presets", Content: pre.Object()},', + "TestTheWindowSaysNothingItDoesNotSayFromTheTextPackage", + ), + ( + # The other half, and the one the guard missed on its first run. Glueing + # a value onto a literal hides the literal from a check that reads + # arguments alone - and it is also the shape that translates worst, + # because languages disagree about where a name goes in a phrase. + "a sentence goes back to being glued together in the window", + "internal/gui/window/preset.go", + "parts.Subheading(text.PresetCatchesHeading())", + 'parts.Subheading("Typically" + " finds:")', + "TestTheWindowSaysNothingItDoesNotSayFromTheTextPackage", + ), + ( + # The real defect, put back. Each screen used to hold its own output + # box, both starting at the working directory - so they agreed until + # somebody changed one and then silently disagreed. Reported from use + # on 2026-08-11: files went to a chosen directory, the preset screen + # was opened, and its run wrote somewhere else. + "the chosen directory is carried backwards, to the screen somebody just left", + "internal/gui/window/open.go", + "to.SetOutDir(from.OutDir())", + "from.SetOutDir(to.OutDir())", + "TestBothScreensAgreeWhereTheFilesGo", + ), + ( + # And the way back, because carrying it one way leaves the other + # direction exactly as confusing as it was. + "the window forgets which screen somebody came from, so it carries nothing", + "internal/gui/window/open.go", + "showing = item.ID", + "_ = item.ID", + "TestBothScreensAgreeWhereTheFilesGo", + ), + ( + # A control that is always there and never usable is a question the + # screen keeps asking and answering itself. Reported from looking at the + # window on 2026-08-11. This takes away the hiding at the END of a run, + # which the constructor's hiding covers up until the first run finishes. + "cancel sits on the screen with nothing to cancel", + "internal/gui/window/runbusy.go", + "\tb.cancel.Disable()\n\tb.cancel.Hide()\n\tb.bar.Hide()", + "\tb.cancel.Disable()\n\tb.bar.Hide()", + "TestCancelIsOnlyThereWhenThereIsSomethingToCancel", + ), + ( + # The way between screens used to be a button in the row of actions + # under the last field, so changing screen meant scrolling past the + # whole form. Reported from use 2026-08-11. Tabs added while the buttons + # stay is the likely half done move, and it looks fixed from the top. + "the way between screens goes back to a button at the foot", + "internal/gui/window/open.go", + "\tgen := NewGenerate(h)\n\tpre := NewPreset(h)", + "\tgen := NewGenerate(h, parts.NewButton(parts.Secondary, text.TabPresets(), func() {}))\n\tpre := NewPreset(h)", + "TestMovingBetweenScreensIsTabsAndNotButtons", + ), + ( + # The focus colour painted rather than blended, which is what the + # window shipped until 2026-08-12: the toolkit fills a whole menu with + # this name and writes the chosen value across it, so an opaque value + # put the format's name on solid blue at 2.28:1 against a threshold of + # 4.5. Reported from the screen as a field that stays lit after the + # choice. + # + # This entry used to lower the colour instead, back to the 1.16:1 the + # window had before the palette was installed. The threshold it was + # aimed at moved on 2026-08-12: the state is carried by a line now, so + # what this colour has to meet is the one a reader needs THROUGH it. + "the focus colour is painted rather than blended", + "internal/gui/parts/theme.go", + # The spacing here is whatever gofmt last decided, and it realigns the + # whole map when the longest key changes - which it did the day hover + # became an overlay. Staleness names it in a second when that happens. + "theme.ColorNameFocus: overlay(0x4C, 0x9D, 0xF0, 0x66),", + "theme.ColorNameFocus: hex(0x4C, 0x9D, 0xF0),", + "TestThePaletteMeetsTheContrastItWasComputedFor", + ), + ( + # A field's name in the value's ink: two names, one colour, and the + # form has one ink where the owner asked for two. + "a field's name is inked like the value under it", + "internal/gui/parts/theme.go", + "ColorNameLabel: hex(0xCF, 0xD3, 0xD8),", + "ColorNameLabel: hex(0xE6, 0xE6, 0xE6),", + "TestThePaletteMeetsTheContrastItWasComputedFor", + ), + ( + # The program is dark only by the owner's decision, and the toolkit + # hands the system's variant to every colour lookup - so branching on + # it is one plausible looking edit away, and it would put a screen + # nobody has ever seen on somebody else's desktop. + "the window follows the desktop's light setting again", + "internal/gui/parts/theme.go", + "\tif c, ok := darkColours[name]; ok {\n\t\treturn c\n\t}\n\treturn o.Theme.Color(name, theme.VariantDark)", + "\tpalette := darkColours\n\tif variant == theme.VariantLight {\n\t\tpalette = lightColours\n\t}\n\tif c, ok := palette[name]; ok {\n\t\treturn c\n\t}\n\treturn o.Theme.Color(name, variant)", + "TestTheWindowKeepsOneLookWhateverTheDesktopSays", + ), + ( + # The light palette is kept measured although it is not installed, so + # the numbers are there the day it is wanted. This is what proves they + # are still real numbers rather than a copy of the dark ones. + "the light palette stops being a palette of its own", + "internal/gui/parts/theme.go", + "palette := darkColours\n\tif variant == theme.VariantLight {\n\t\tpalette = lightColours\n\t}", + "palette := darkColours", + "TestTheLightAndDarkPalettesAreNotTheSame", + ), + ( + # The sweep found twelve of these by hand and the guard found three + # more the same minute, in files nobody had thought to look in. The + # defect is the dashes rather than which word follows them, so the + # guard asks about the syntax and this puts one back. O79. + "a message below the surfaces goes back to flag spelling", + "internal/format/wav/wav.go", + ', or drop the label"', + ', or drop the label with --clean"', + "TestNoMessageBelowTheSurfacesIsWrittenInFlagSpelling", + ), + ( + # The preset screen names its settings from the preset's own parameters + # rather than from the recipe keys the engine uses, so the two screens + # share the mechanism and not the vocabulary. This was the half left + # undone when the generate screen's refusals were moved. O73. + "a preset refusal stops saying which parameter it is about", + "internal/preset/limitset.go", + 'Setting: s.setting,', + 'Setting: "",', + "TestARefusalAboutAPresetSettingAppearsUnderIt", + ), + ( + # Without the setting on the error the window has nothing to place the + # message by, and it falls back to the foot of the form - which is + # where it sat, 748 px below the field it named. O73. + "a refusal stops saying which setting it is about", + "internal/engine/engine.go", + '&RecipeError{Setting: SettingCount,\n\t\t\tDetail: fmt.Sprintf("target %q asks for 0 files", t.ID),', + '&RecipeError{\n\t\t\tDetail: fmt.Sprintf("target %q asks for 0 files", t.ID),', + "TestARefusalAboutOneSettingAppearsUnderIt", + ), + ( + # Moving a message is easy to do badly: showing it in both places looks + # fixed in a screenshot of the top of the screen and is two copies of + # one refusal. + "the refusal is shown at the foot as well as beside the field", + "internal/gui/window/runrefuse.go", + # Repointed 2026-09-23, when placing a refusal moved out of the loop + # into placed() and the loop lost a level. + "\t\t\t\tfirst = where\n\t\t\t}\n\t\t\tcontinue\n\t\t}", + "\t\t\t\tfirst = where\n\t\t\t}\n\t\t}", + "TestARefusalAboutOneSettingAppearsUnderIt", + ), + ( + # A VBox hands its children the whole width it was given, which is the + # whole window. Measured at 3862 px: every box was 3848 to 3854 px, so + # the seed field holding "0" was nearly four thousand pixels wide. O72. + "the form stretches to the window again", + "internal/gui/parts/parts.go", + "\twidth := fyne.Min(size.Width, ColumnWidth)", + "\twidth := size.Width", + "TestTheFormDoesNotRunToTheEdgeOfTheWindow", + ), + ( + # A change of look that the structural guards pass. Measured with + # try-one on 2026-08-17: TestABoxForANumberIsTheWidthOfANumber STAYED + # GREEN, because it compares the box against the constant rather than + # against a number - widen the constant and it widens what it allows. + # The stored picture is the only thing that notices. + "the numeric fields get wider", + "internal/gui/parts/tokens.go", + "\tNumericWidth = 140", + "\tNumericWidth = 260", + "TestEveryScreenStillDrawsItsStoredPicture", + ), + ( + # Everything else about this writer is a refusal, and a writer that + # refuses correctly while putting the wrong bytes down would pass all of + # them. This is the plain case, broken plainly. + "the replacement writes something other than what was asked for", + "internal/core/replace.go", + "\tif _, err := f.Write(content); err != nil {", + "\tif _, err := f.Write(content[:0]); err != nil {", + "TestReplacingPutsTheContentThatWasAskedFor", + ), + ( + # The real defect, put back. A rename asks for permission on the + # DIRECTORY rather than on the file, so read only stops a replacement on + # Windows and does not stop it on Linux - measured on both with + # tools/probes/atomic-replace. Refusing everywhere is the same rule this + # project applies to file names that only work on one system. + "a read only file stops being protected", + "internal/core/replace.go", + "\tif mode&0o200 == 0 {", + "\tif mode&0o200 == 0 && false {", + "TestAReadOnlyFileIsLeftAlone", + ), + ( + # The half written copy sits in somebody's repository, because a rename + # across volumes is not one operation and it has to be beside the target. + # The version this replaced cleaned up after a failed rename and not + # after a failed write. + "a failed replacement leaves its half written copy behind", + "internal/core/replace.go", + "\tif err := os.Rename(tmp, path); err != nil {\n\t\t_ = os.Remove(tmp)", + "\tif err := os.Rename(tmp, path); err != nil {", + "TestReplacingLeavesNoHalfWrittenCopyBehind", + ), + ( + # The arrangement the guard above rests on. A test that sets up a failure + # it never triggers reports a clean directory because nothing happened in + # it, which is the same shape as a guard that never reaches the code. + "replacing onto a non empty directory stops failing", + "internal/core/replace.go", + "\tif err := os.Rename(tmp, path); err != nil {\n\t\t_ = os.Remove(tmp)\n\t\treturn &ReplaceError{Path: path, Err: err}\n\t}", + "\tif err := os.Rename(tmp, path); err != nil {\n\t\t_ = os.Remove(tmp)\n\t\t_ = err\n\t}", + "TestTheRenameFailureThatGuardIsBuiltOnReallyHappens", + ), + ( + # The real defect, put back. Of the four fields on those two rows the + # size was the only one with nowhere for its refusal to land, and it is + # the one most likely to get one - every format has a minimum. The + # message appeared at the foot of the form, 900 px under the box. + "a refusal about the size goes back to the foot of the form", + "internal/format/format.go", + "func (e *BelowMinimumError) AboutSetting() string { return SettingSize }", + 'func (e *BelowMinimumError) AboutSetting() string { return "" }', + "TestARefusalAboutTheSizeAppearsUnderIt", + ), + ( + # The real defect, put back. The quiet look paints no fill and no + # border at rest, so the way to stop a run arrives as bare text between + # two filled buttons and reads as a disabled label. + "Cancel goes back to the quiet look, which paints no surface", + "internal/gui/window/run.go", + "\tcancel := parts.NewButton(parts.Secondary, text.ButtonCancel(), r.onCancel)", + "\tcancel := parts.NewButton(parts.Quiet, text.ButtonCancel(), r.onCancel)", + "TestCancelIsDrawnAsAButton", + ), + ( + # The label switch stands under its name in the column of names since + # 2026-09-15. Registered with no name it goes back to a bare square with + # nothing to read beside it - the O72 defect the grid answers. + "the label switch is registered with no name", + "internal/gui/parts/fields.go", + "return s.Add(setting, name, hint, detail, check)", + 'return s.Add(setting, "", hint, detail, check)', + "TestTheLabelSwitchStandsUnderItsName", + ), + ( + # The focus ring drawn for the pointer as well as the keyboard, which is + # what the toolkit did and what O207 is about: a mark meaning "the + # keyboard is here" shown to somebody using the mouse. + "the button draws its ring for the pointer too", + "internal/gui/parts/button.go", + "\tb.hovered = true", + "\tb.hovered = true\n\tb.marked = true", + "TestAButtonDrawsItsRingForTheKeyboardNotThePointer", + ), + ( + # The press forgotten only in MouseUp, which never comes back when the + # pointer slides off before releasing - so the button stays drawn as + # pressed for good (the Fyne guide, section 3.27). + "the button keeps a press after the pointer leaves", + "internal/gui/parts/button.go", + "\tb.hovered, b.pressed = false, false", + "\tb.hovered = false", + "TestAButtonForgetsAPressWhenThePointerLeaves", + ), + ( + # The defect put back exactly as it was written on 2026-09-15: the + # desktop driver delivers one press of space as the key AND the + # character, so a button answering both is pressed twice. Green in + # every guard that sent one call, red in the one that sends both. + "the button presses on the space character as well as the space key", + "internal/gui/parts/button.go", + "func (b *Button) TypedRune(rune) {}", + "func (b *Button) TypedRune(r rune) {\n\tif r == ' ' {\n\t\tb.Tapped(nil)\n\t}\n}", + "TestOnePressOfSpacePressesAButtonOnce", + ), + ( + # The switch stops taking the keyboard on a press, which is where it + # stood from 2026-09-15 to 2026-09-16: the driver unfocuses whatever + # had the keyboard and the switch left it nowhere, so the next Space + # flipped nothing and the next Tab started from the top. Found by an + # outside review of #103. + "a press on the switch stops taking the keyboard", + "internal/gui/parts/toggle.go", + "\tt.from.Take(t)\n\tt.SetChecked(!t.Checked)", + "\tt.SetChecked(!t.Checked)", + "TestAPressMovesTheKeyboardWithoutDrawingItsMark", + ), + ( + # The mark drawn for a press: the blue disc of 2026-08-18, in the new + # switch's shape. FocusGained stops asking who brought the keyboard. + "a press on the switch draws the keyboard mark", + "internal/gui/parts/toggle.go", + "\tif !t.from.Draws() {\n\t\treturn\n\t}\n\tt.mark()", + "\tt.mark()", + "TestAPressMovesTheKeyboardWithoutDrawingItsMark", + ), + ( + # The defect as it was written: one press of space is the key and the + # character, and a switch answering both flips twice, back to where + # it started. + "the switch flips on the space character as well as the key", + "internal/gui/parts/toggle.go", + "func (t *Toggle) TypedRune(rune) {}", + "func (t *Toggle) TypedRune(r rune) {\n\tif r == ' ' {\n\t\tt.Tapped(nil)\n\t}\n}", + "TestOnePressOfSpaceFlipsASwitchOnce", + ), + ( + # The same drift on the segmented switch: a press chooses and leaves + # the keyboard nowhere. + "a press on a segment stops taking the keyboard", + "internal/gui/parts/segments.go", + "\ts.from.Take(s)\n\tif at := s.segmentAt(event.Position.X); at >= 0 {", + "\tif at := s.segmentAt(event.Position.X); at >= 0 {", + "TestAPressOnASegmentedSwitchPutsTheKeyboardOnItQuietly", + ), + ( + # A frozen switch that kept the keyboard moves its choice on the + # arrows under a form drawn as frozen - the focus manager asks + # Disabled only when it moves the focus. + "a frozen segmented switch answers the arrows again", + "internal/gui/parts/segments.go", + "\tif event == nil || s.Disabled() || len(s.Options) == 0 {", + "\tif event == nil || len(s.Options) == 0 {", + "TestAFrozenControlAnswersNoKey", + ), + ( + # And the menu, through the toolkit's own Select.TypedKey, which asks + # nothing about Disabled before moving the value on Left and Right. + "a frozen menu answers the arrows again", + "internal/gui/parts/ring.go", + "\tif event == nil || c.Disabled() {\n\t\treturn\n\t}\n\t// The keyboard has been used", + "\t// The keyboard has been used", + "TestAFrozenControlAnswersNoKey", + ), + ( + # Back to the space bar alone, which is what the toolkit answers - so + # Enter on a focused button does nothing, the silence this window + # refuses everywhere else. + "the button answers the space bar and not Enter", + "internal/gui/parts/button.go", + "\tcase fyne.KeyReturn, fyne.KeyEnter, fyne.KeySpace:", + "\tcase fyne.KeySpace:", + "TestAButtonIsPressedByEnterAsWellAsSpace", + ), + ( + # A switch of fixed choices that takes a value it does not hold, which + # is a box pretending to be a switch - a copy of a batch could land it + # on a way of stating a size that is not one of the three. + "the segmented switch takes a value it does not offer", + "internal/gui/parts/segments.go", + "\tif value == s.Selected || s.indexOf(value) < 0 {", + "\tif value == s.Selected {", + "TestASegmentedSwitchIgnoresAValueItDoesNotHold", + ), + ( + # The right arrow stops moving the choice, so the switch is a row the + # keyboard can land on and not step through - which is the whole of what + # a segmented control is over a label. + "the segmented switch stops moving on the right arrow", + "internal/gui/parts/segments.go", + "\t\t\ts.SetSelected(s.Options[at+1])", + "\t\t\ts.SetSelected(s.Options[at])", + "TestASegmentedSwitchMovesTheChoiceWithTheArrows", + ), + ( + # Half of every field's explanation lives behind this button since + # 2026-08-12. Without the button those sentences are written, shipped + # and unreadable - which is worse than the wall of text they replaced, + # because the wall could at least be read. + # The button is added by the name cell of a field row since the form + # became a grid (2026-09-14). The line this entry used to break lived + # in withDetail, which nothing had called since that day - the full + # run of 2026-09-16 found the entry green and the function dead. + "the longer explanation loses the button that opens it", + "internal/gui/parts/required.go", + "\t\trow = append(row, newDetailButton(detail))", + "\t\t_ = newDetailButton(detail)", + "TestTheLongerExplanationOpensWhenAsked", + ), + ( + # Reported on 2026-08-12: it opened on a click, and people point at a + # small letter i and wait. The toolkit has no tooltip - measured, there + # is no such identifier anywhere in v2.8.0 - so hovering is three + # methods answered by hand, and a hook answered but never wired looks + # from outside exactly like one that works. + "the explanation stops opening when the pointer arrives", + "internal/gui/parts/detail.go", + "func (b *DetailButton) MouseIn(e *desktop.MouseEvent) {\n" + "\tb.Button.MouseIn(e)\n" + "\tb.show()", + "func (b *DetailButton) MouseIn(e *desktop.MouseEvent) {\n" + "\tb.Button.MouseIn(e)", + "TestTheLongerExplanationOpensWhenAsked", + ), + ( + # The real defect, put back. widget.PopUp wraps itself in an overlay + # container that covers the canvas and declares itself Hoverable with + # empty methods, so opening the explanation stole the hover that was + # keeping it open - close, uncover, reopen, at the speed of mouse + # movement. Reported by somebody moving a mouse, invisible to the + # guard that opens it and reads it. + # Through the canvas's own overlay stack rather than widget.PopUp: + # detail.go stopped importing widget when DetailButton moved onto + # our own Button (2026-09-15), and a mutant naming a package the file + # does not import is BROKEN, which proves nothing. + "the explanation goes back to the overlay layer", + "internal/gui/parts/detail.go", + "\tt.sheet.Add(box)\n\tt.sheet.Refresh()\n\treturn box", + "\tdriver.CanvasForObject(near).Overlays().Add(box)\n" + "\treturn box", + "TestTheExplanationNeverUsesTheOverlayLayer", + ), + ( + # O217, the half nothing behavioural can see. The sheet gains a box and + # says nothing, so the box waits for a repaint from anywhere - which + # the pointer's path supplies and the keyboard's never does. The test + # driver's canvas ignores Refresh, so this is held by reading the + # code: a container field that is added to or removed from is + # refreshed in the same function. + "the sheet takes a box off and never says so", + "internal/gui/parts/detail.go", + "\tt.sheet.Remove(box)\n\tt.sheet.Refresh()", + "\tt.sheet.Remove(box)", + "TestABoxThatGainsOrLosesAPieceSaysSo", + ), + ( + # The real defect of 2026-09-16, put back (O217). r.button is the + # Button embedded in DetailButton, and the tree holds the DetailButton, + # so the driver has no canvas filed under this value: nothing is marked + # dirty and the explanation put on the sheet is never painted. The + # test driver answers CanvasForObject for any object, so only the + # reading of the renderer sees it. + "a renderer asks the canvas about the widget instead of its pieces", + "internal/gui/parts/button.go", + "\tredraw(r.bg, r.ring, r.label, r.icon)\n\tr.Layout(r.button.Size())", + "\tcanvas.Refresh(r.button)\n\tr.Layout(r.button.Size())", + "TestARendererRedrawsThePiecesItDrawsAndNeverTheWidget", + ), + ( + # And the other silence: a face that changes and tells nobody at all. + "a renderer changes its face and tells the canvas nothing", + "internal/gui/parts/progress.go", + "\tr.Layout(r.bar.Size())\n\tredraw(r.track, r.fill)", + "\tr.Layout(r.bar.Size())", + "TestARendererRedrawsThePiecesItDrawsAndNeverTheWidget", + ), + ( + # The quieter silence an outside review of #106 pointed at: three + # pieces named and the fourth left out. The tick is only ever shown or + # hidden, and Show on a canvas image sets Hidden and stops (fyne + # v2.8.1 canvas/base.go), so a tick shown and not named stays unpainted + # until something else repaints. Passed the guard until 2026-09-16, + # which counted the arguments of redraw and asked nothing of them. + "a renderer names three of the four pieces it changes", + "internal/gui/parts/toggle.go", + "\tredraw(r.halo, r.ring, r.square, r.tick)", + "\tredraw(r.halo, r.ring, r.square)", + "TestARendererRedrawsThePiecesItDrawsAndNeverTheWidget", + ), + ( + # The same, one method away: the fill's colour is set in applyColours, + # not in Refresh, so a reader stopping at Refresh reads nothing about + # it. The guard follows the renderer into its own methods. + "a renderer colours a piece in a helper and never names it", + "internal/gui/parts/progress.go", + "\tr.Layout(r.bar.Size())\n\tredraw(r.track, r.fill)", + "\tr.Layout(r.bar.Size())\n\tredraw(r.track)", + "TestARendererRedrawsThePiecesItDrawsAndNeverTheWidget", + ), + ( + # The refresh is there and stands BEFORE the change, so it repaints + # the sheet with the box still on it and the removal is as silent as + # no refresh at all. Any refresh anywhere in the method satisfied the + # guard until 2026-09-16 - the second thing the review of #106 named. + "the sheet says so first and takes the box off after", + "internal/gui/parts/detail.go", + "\tt.sheet.Remove(box)\n\tt.sheet.Refresh()", + "\tt.sheet.Refresh()\n\tt.sheet.Remove(box)", + "TestABoxThatGainsOrLosesAPieceSaysSo", + ), + ( + # A button built hidden opens its explanation to a call and to nobody + # with a pointer. Caught only since the guard moves the pointer through + # the canvas (2026-09-16): the call it made before reached a hidden + # button as readily as a drawn one. + "the explanation button is built hidden", + "internal/gui/parts/detail.go", + "\tb.OnTapped = b.toggle\n\treturn b", + "\tb.OnTapped = b.toggle\n\tb.Hide()\n\treturn b", + "TestTheLongerExplanationOpensWhenAsked", + ), + ( + # A tooltip that opens and never closes is worse than one that never + # opens, because it covers the field underneath it. + "the explanation stays on screen after the pointer leaves", + "internal/gui/parts/detail.go", + "func (b *DetailButton) MouseOut() {\n" + "\tb.Button.MouseOut()\n" + "\tb.hide()", + "func (b *DetailButton) MouseOut() {\n" + "\tb.Button.MouseOut()", + "TestTheLongerExplanationOpensWhenAsked", + ), + ( + # The other half: the button is there and opens nothing. A control that + # does nothing is the version somebody would ship without noticing, + # because the screenshot looks identical. + "the button beside a field opens nothing", + "internal/gui/parts/detail.go", + "\tt.sheet.Add(box)\n\tt.sheet.Refresh()\n\treturn box", + "\treturn box", + "TestTheLongerExplanationOpensWhenAsked", + ), + ( + # The real defect, put back. Capping the column stopped a field at + # 820 px, which is still a paragraph's width offered for the digit 0. + # Measured before the fix: the seed was 397 px and the count 399. + "a box for a number goes back to taking the whole column", + "internal/gui/parts/parts.go", + "\treturn Sized(NumericWidth, control)", + "\treturn control", + "TestABoxForANumberIsTheWidthOfANumber", + ), + ( + # The bar's surface runs the window and what stands on it does not, + # which is one line of layout and looks like an accident. Without it a + # refusal about a field 822 px in ran to 1099. + "what the run says stops lining up with the form", + "internal/gui/parts/parts.go", + "\tcolumn := container.New(readableWidth{}, Indented(inner))", + "\tcolumn := Indented(inner)", + "TestTheRunSpeaksInsideTheSameColumnAsTheForm", + ), + ( + # The real defect, put back. A card fills itself with the page colour - + # the toolkit's card.go line 44 - so three sections rendered at exactly + # the page colour, 0.00 L* apart, and the grouping they were introduced + # for did not exist. Every structural guard stayed green through it. + "a section goes back to a card and stops drawing a surface", + "internal/gui/parts/parts.go", + # Repointed 2026-09-23, when a section's content became a Grid. + "\treturn container.NewStack(panelSurface(), Padded(Inset, Column(GapField, sectionTitle(title), Grid(content...))))", + '\treturn widget.NewCard(title, "", container.NewVBox(content...))', + "TestASectionDrawsItsOwnSurface", + ), + ( + # The same defect reached the other way: the surface is still drawn, and + # painted with the colour of the thing behind it. That is what a card + # was doing, and it is the version a palette edit could reintroduce + # without touching a line of layout. + "the panel is painted with the page colour", + "internal/gui/parts/parts.go", + "rect := canvas.NewRectangle(PaletteColour(ColorNamePanel, theme.VariantDark))", + "rect := canvas.NewRectangle(PaletteColour(theme.ColorNameBackground, theme.VariantDark))", + "TestASectionDrawsItsOwnSurface", + ), + ( + # The mistake that was nearly made on the way in. docs/UX.md section 8.2 + # records a surface at #2E2E30 and reaching for it is the obvious move - + # but that value is what an input box is painted with, so the panel + # would have swallowed every field standing on it. Grouped screen, + # invisible controls. + "the panel takes the colour an input box already uses", + "internal/gui/parts/theme.go", + "ColorNamePanel: hex(0x2A, 0x2A, 0x2D),", + "ColorNamePanel: hex(0x38, 0x38, 0x3D),", + "TestEachSurfaceIsToldFromTheOneUnderIt", + ), + ( + # The real defect, put back. The set named its files after the DISTANCE + # from the limit and never after the limit, so "at_limit.pdf" from a + # 10mb run and from a 5mb run are one name for two different claims. + # Reported from use on 2026-08-11. The full suite stayed green when the + # names changed, which is why the guard had to be written. + "the limit disappears from the names of a boundary set", + "internal/preset/limitset.go", + "\t\t\tName: s.limitText + \"_\" + one.id + s.desc.Extension,\n", + "\t\t\tName: one.id + s.desc.Extension,\n", + "TestEveryBoundaryFileNamesTheLimitItWasBuiltAround", + ), + ( + # A button that asks and drops the answer looks exactly like one that + # works. The picker itself needs a real window, so what is provable is + # the wiring - and the wiring is the part that goes wrong. + "the chosen directory never reaches the field", + "internal/gui/window/open.go", + " box.SetText(path)", + " _ = path", + "TestBrowsingForADirectoryPutsItInTheField", + ), + ( + # Cancelling a picker answers with nothing, and a field emptied by + # cancelling sends the run at a directory nobody named. + "cancelling the picker empties the directory field", + "internal/gui/window/open.go", + " if path != \"\" {", + " if path == path {", + "TestCancellingTheDirectoryPickerLeavesTheFieldAlone", + ), + ( + # The notices used to ask one direction only: is every listed module + # still built. So a module that ARRIVED went unlisted and unnoticed, + # which is the direction that matters - their licences require the + # notice to travel with the code that ships. Found on 2026-08-05 when + # the folder picker brought one in. + "a module ships in the window with its notice removed", + "THIRD-PARTY-NOTICES.md", + "| `github.com/FyshOS/fancyfs` | v0.0.1 | BSD-3-Clause | (C) 2025 FyshOS developers (see AUTHORS) |\n", + "", + "TestTheNoticesFileNamesTheVersionsThatAreActuallyBuilt", + ), + ( + # The ceiling counts code and not explanation, and until 2026-08-05 + # nothing proved the exclusion still worked. It would not have shown up + # as a wrong number - it would have shown up as a function suddenly over + # the ceiling for having been explained, which reads like a real + # finding. Breaks the measure rather than the guard reading it. + "the size ceiling starts counting comments as code", + "internal/guard/codeshape_test.go", + "\t\tif comments[line] {\n\t\t\tcontinue\n\t\t}", + "\t\tif comments[line] && false {\n\t\t\tcontinue\n\t\t}", + "TestTheSizeCeilingCountsCodeAndNotExplanation", + ), + ( + # The classification is the subject here, so the classification is + # what gets broken. Until 2026-08-05 the UTF-8 check walked a list + # written by hand and nothing asked whether the list still covered + # the registry - so a format added later would simply not have been + # checked, with the suite green and covering less. + "a registered format is on neither the text nor the binary list", + "internal/guard/textformats_test.go", + 'var binaryFormats = []string{"avif", "bmp", "docx", "gif", "ico", "jpg", "jxl", "pdf", "png", "pptx", "targz", "tiff", "wav", "webp", "xlsx", "zip"}', + 'var binaryFormats = []string{"avif", "bmp", "docx", "gif", "ico", "jpg", "jxl", "pdf", "png", "pptx", "targz", "tiff", "wav", "webp", "xlsx"}', + "TestEveryFormatIsClassifiedAsTextOrBinary", + ), + ( + # Found by coverage rather than by reading: onCancel came back at + # nought per cent while every guard around it was green. The button was + # on the screen and in the brief, and nothing pressed it. + "the Cancel button stops reaching the run", + "internal/gui/window/run.go", + "func (r *runner) Stop() {\n\tif r.stop != nil {", + "func (r *runner) Stop() {\n\tif r.stop != nil && false {", + "TestCancelStopsTheRun", + ), + ( + # O63. Fyne 2.8 warns at every start that changes have to go through + # fyne.Do and says the next major release stops tolerating it. Measured + # on 2026-08-05: under the test driver fyne.Do runs on the calling + # goroutine and a widget touched from a worker does not complain, so + # neither -race nor a rendering test can see this. Static reading is + # what is left. + "the window is told the run finished straight from the worker", + "internal/gui/window/run.go", + "\t\tfyne.Do(func() { r.runFinished(res, runErr, saveErr, room, saved) })", + "\t\tr.runFinished(res, runErr, saveErr, room, saved)", + "TestTheWindowIsOnlyTouchedFromTheInterfaceThread", + ), + ( + # The owner decided on 2026-08-05 that the window opens on the work. + # A notice shown at every start is one nobody reads twice, and what the + # rule actually asks is that the licence can be reached. + # Which tab is first is what "opens on the work" means since the screens + # became tabs on 2026-08-11. Putting the notice first is the same defect + # the owner decided against on 2026-08-05, in its new shape. + "the window goes back to opening on the licence notice", + "internal/gui/window/open.go", + "&parts.Tab{ID: tabGenerate, Text: text.TabOneTarget(), Content: gen.Object()},", + "&parts.Tab{ID: tabAbout, Text: text.TabAbout(), Content: About(h)},", + "TestTheWindowOpensOnTheGenerateScreen", + ), + ( + # The other half of the same decision. Moving the notice off the opening + # screen is only safe while it can still be got to, and since it became + # a tab on 2026-08-11 the way to lose it is to drop the tab - which also + # takes away the way back, because a tab was both. + "the licence tab is removed, so the notice becomes unreachable", + "internal/gui/window/open.go", + "&parts.Tab{ID: tabAbout, Text: text.TabAbout(), Content: About(h)},", + "&parts.Tab{ID: tabAbout, Text: text.TabAbout() + \"!\", Content: About(h)},", + "TestTheLicenceIsStillReachableFromTheOpeningScreen", + ), + ( + # A version typed once beside the code rather than derived from it. + # Three of them went stale within hours of being written, when raising + # x/image to clear four vulnerabilities moved it, x/net and x/sys. + # Their licences ask the notice to travel with the code that ships, so + # the number has to come from the build. + # Breaks the subject rather than the judge: the number in the file goes + # back to what it was before x/image was raised. Disabling the guard's + # own comparison would leave it green, since the versions agree today, + # and would prove nothing about whether drift is caught. + "the notices file goes back to naming a version that is no longer built", + "THIRD-PARTY-NOTICES.md", + "| `golang.org/x/net` | v0.60.0 |", + "| `golang.org/x/net` | v0.35.0 |", + "TestTheNoticesFileNamesTheVersionsThatAreActuallyBuilt", + ), + ( + # The real defect, put back, and it is the one the guard above told a + # person to fix by hand: "change the counts in the paragraph". Nobody + # did. The paragraph said twelve BSD 3-Clause and nine MIT while the + # table held thirteen and ten, and two internal documents copied the + # wrong pair onwards. This file is the one artefact here that exists to + # satisfy somebody else's licence, so a reader who counts the rows and + # finds the sentence disagreeing cannot tell which half is wrong. + "the notices count a licence differently from the table beneath them", + "THIRD-PARTY-NOTICES.md", + "thirteen BSD 3-Clause, nine MIT", + "twelve BSD 3-Clause, eight MIT", + "TestTheNoticesCountsAgreeWithTheTableTheyDescribe", + ), + ( + # Found by fuzzing 2026-08-05. The text of a distance becomes the id of + # a target and the name of a file, and a carriage return reaches the + # recipe source raw - the size parser trims the ends and that one sits + # in the middle. The document then does not parse at all. + "a distance carrying a character a name cannot is let through", + "internal/preset/sizeboundaries.go", + 'if bad := firstUnusable(piece); bad != "" {', + 'if bad := firstUnusable(piece); bad != "" && false {', + "TestEveryPresetEndingHasTheCodeItShould", + ), + ( + # The engine writes one sentence and both surfaces show it word for + # word, so a spelling only one of them takes sends the other's reader + # translating. Seen on a screenshot of the refused preset screen on + # 2026-08-11: it said "Raise --limit", and the window labels that field + # "limit" and has no flags on it at all. O79. + "the refusal goes back to naming a command line flag", + "internal/preset/limitset.go", + '"Raise the {setting} above %d B, narrow the spread,', + '"Raise --limit above %d B, narrow the spread,', + "TestSizeBoundariesRefusesASetItCannotCompleteRatherThanPartOfIt", + ), + ( + # Two equal distances make two steps of the set that are the same file + # twice, and they collide on the id built from the distance. Compared as + # bytes, so 1024 and 1kb are caught as well as 1B and 1b. + "the same distance twice builds a set holding that step twice", + "internal/preset/build.go", + "if first, repeated := seen[key]; repeated {", + "if first, repeated := seen[key]; repeated && false {", + "TestEveryPresetEndingHasTheCodeItShould", + ), + ( + # A bad spread used to be a plain error, which falls through the + # classifier to RUNTIME - so a mistyped flag value told CI this program + # had a bug. The same class as "--set width=abc", fixed two days before + # for the same reason. + "a mistyped distance is reported as a fault of the tool again", + "internal/preset/build.go", + "\treturn &format.PropertyValueError{\n\t\tFormat: l.preset, Key: l.param, Value: value, Reason: reason,\n\t}\n", + "\treturn fmt.Errorf(\"%s: %s cannot be %q - %s\", l.preset, l.param, value, reason)\n", + "TestEveryPresetEndingHasTheCodeItShould", + ), + ( + # The state before 2026-08-12, put back. A refused value was answered + # with a paragraph and the box it was typed into looked like every + # other one, so on a form of eight settings the reader finds the + # sentence before they can find the field. + "a refused box goes back to looking like every other box", + "internal/gui/parts/field.go", + "\ta.box.Show()\n\ta.mark(true)", + "\ta.box.Show()", + "TestTheBoxARefusalIsAboutIsMarked", + ), + ( + # The half nothing on the screen looks wrong for: a mark left behind + # after the value was fixed describes a state that is no longer true, + # and the next press is refused by a screen already showing a refusal. + "the mark stays on a box whose value was fixed", + "internal/gui/parts/field.go", + "\ta.mark(false)\n}", + "\ta.mark(true)\n}", + "TestTheMarkGoesWhenTheValueIsFixed", + ), + ( + # The wiring between the sentence and the edge, cut. The area still + # says what happened and the box says nothing, which is the shape this + # started as and looks correct at every call site. + "the refusal is no longer wired to the box it is about", + "internal/gui/parts/field.go", + "\tarea := NewErrorArea()\n\tarea.edge = ring\n\tpieces := ", + "\tarea := NewErrorArea()\n\t_ = ring\n\tpieces := ", + "TestTheBoxARefusalIsAboutIsMarkedOnThePresetScreenToo", + ), + ( + # The keyboard arrives and nothing says so. The toolkit's own mark for + # a menu is a fill nobody can read a value through, which is why this + # line exists at all - so losing it leaves focus invisible rather than + # merely quieter. + "a menu stops saying when the keyboard is in it", + "internal/gui/parts/ring.go", + "c.ring.Focus(true)", + "c.ring.Focus(false)", + "TestTheMenuTheKeyboardIsInDrawsALine", + ), + ( + # The order between the two states, swapped. A refused box with the + # keyboard in it draws the focus colour, so the one mark that says the + # run will not start is the one that disappears when somebody goes to + # fix it. + "focus outranks a refusal, so a refused box loses its mark when used", + "internal/gui/parts/ring.go", + "\tcase r.refused:", + "\tcase r.focused:", + "TestARefusedBoxStaysRedWhileTheKeyboardIsInIt", + ), + ( + # Registration stops putting a closed set in order, so the order is + # whatever the declaration was typed in - which is where the pdf paper + # sizes were a4, a3, a5, letter, legal. + "a closed set is registered in whatever order it was written", + "internal/format/registry.go", + "\t\tSortChoices(d.Properties[i].Choices)", + "\t\t_ = i", + "TestEveryClosedSetIsRegisteredInOrder", + ), + ( + # Numbers sorted as words. The WAV bit depths come out 16, 24, 32, 8, + # which is alphabetical and is the reason this is not a plain sort. + "numbers in a closed set are sorted as words", + "internal/format/registry.go", + "if numeric(choices) {", + "if numeric(choices) && false {", + "TestAClosedSetOfNumbersIsInNumericOrder", + ), + ( + # The registry hands its ids back in map order, which is deliberately + # random in Go. Both menus are built straight from this. + "the list of formats comes back in whatever order the map likes", + "internal/format/registry.go", + "\tsort.Strings(out)\n\treturn out", + "\treturn out", + "TestTheFormatAndPresetMenusAreInOrder", + ), + ( + # The state as it shipped: the preset screen draws the parameters a + # preset declares and not the global flags it supplies a value for, so + # size-boundaries can only ever be built as PDFs from the window. + "the preset screen stops offering the global settings a preset reads", + "internal/gui/window/preset.go", + "settings = append(settings, chosen.Globals()...)", + "_ = chosen.Globals()", + "TestThePresetScreenCanBuildTheSetInAnyFormat", + ), + ( + # Everything typed on the preset screen is dropped on the way to the + # expansion, so the window runs the preset at its declared defaults + # while the command line runs what was asked for. The drift D1 is about, + # in the one place the two surfaces cannot share code. + "what was typed on the preset screen never reaches the expansion", + "internal/gui/window/preset.go", + "p.last.of(p.host, p.pick.Selected, p.given())", + "p.last.of(p.host, p.pick.Selected, nil)", + "TestChoosingTheFormatGivesTheSameSetOnBothSurfaces", + ), + ( + # The preview goes back to saying how many and how big without saying + # what. On the preset screen that is the one fact nothing else on the + # screen carries, because a preset states its own targets. + "the preview stops saying what kind of files it would write", + "internal/gui/window/runsummary.go", + " formats: formatsOf(planned),", + " formats: nil,", + "TestThePreviewSaysWhatKindOfFilesItWouldWrite", + ), + ( + # A box built outside the registry, which is the state the whole screen + # was in for the format menu, the seed and every setting a format + # declares. It looks completely ordinary at the call site - that is why + # the answer had to become "there is no other way to build one". + "a field is built without registering it, so nothing can mark it", + "internal/gui/window/generate.go", + "add(engine.SettingSeed, text.FieldSeed(),", + 'add("", text.FieldSeed(),', + "TestEveryBoxOnAScreenIsOneThatCanBeMarked", + ), + ( + # The registry knows every field and loses the message on the way. From + # outside this is indistinguishable from the wiring being right, which + # is the shape a window ends up in when somebody fixes the lookup and + # not the delivery. + "a marked field is found and never told", + "internal/gui/parts/fields.go", + # Repointed 2026-09-23, when a refusal started offering its own fix. + "\tf.Say(inTheWordsOnScreen(f, err))\n\tif label, apply := fixFor(f, err)", + "\t_ = f\n\tif label, apply := fixFor(f, err)", + "TestEveryRegisteredFieldCanBeMarkedAndUnmarked", + ), + ( + # The fields a format declares are drawn and not registered, so the + # boxes that exist for exactly as long as one format is chosen are the + # ones a refusal cannot reach. Fourteen of them across thirteen formats. + "the settings a format declares are drawn outside the registry", + "internal/gui/parts/property.go", + "field := into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(owner, p),", + 'field := into.Add("", text.SettingLabel(p.Name), PropertyDetail(owner, p),', + "TestEveryFormatsOwnSettingsCanBeMarked", + ), + ( + # The colour the open list had until 2026-08-12: the input colour to the + # byte, over a panel 3.8 L* away, with no border. Reported from a + # screenshot as a list where everything runs together. + "an open list goes back to the colour of an input box", + "internal/gui/parts/theme.go", + "theme.ColorNameMenuBackground: hex(0x48, 0x48, 0x4F),", + "theme.ColorNameMenuBackground: hex(0x38, 0x38, 0x3D),", + "TestAnOpenListIsToldFromTheFormBehindIt", + ), + ( + # The toolkit's own behaviour, put back: a list of thirteen formats + # opens with nothing saying which one is in the box, and the box is + # behind the list. + "the open list stops marking the value that is chosen", + "internal/gui/parts/listcontents.go", + "func (c *listContents) isChosen(value string) bool { return value == c.chosen }", + "func (c *listContents) isChosen(value string) bool { return false }", + "TestTheOpenListMarksTheValueThatIsChosen", + ), + ( + # The state this guard was in until 2026-08-13: it asked the compiler + # about the system it happened to be running on. A module built on Linux + # and nowhere else was therefore invisible on Windows, which is exactly + # what happened to github.com/rymdport/portal for a week - listed + # nowhere, shipped in the Linux window, and the guard green. + "the notices are compared against one system instead of every one", + "internal/guard/notices_test.go", + 'for _, goos := range []string{"windows", "linux", "darwin"} {', + 'for _, goos := range []string{"windows"} {', + "TestTheNoticesFileNamesTheVersionsThatAreActuallyBuilt", + ), + ( + # A word a person reads, built outside the one package that holds them. + # The title of the window itself was exactly this until 2026-08-13 and + # the guard could not see it, because it worked from a list of the calls + # that show text and nobody had put the toolkit's NewWindow on that + # list. This puts a literal somewhere no such list would ever name. + "a sentence for a person is built outside the text package again", + "internal/gui/window/run.go", + "text.NotAWholeNumber(field, value)", + '"that is not a whole number"', + "TestTheWindowSaysNothingItDoesNotSayFromTheTextPackage", + ), + ( + # The real defect, put back: a counted noun with brackets round the + # plural. Fourteen lines across seven files wrote this until + # 2026-08-13, while the window had stopped a day earlier - so one run + # was described two ways and the owner read the worse one off a screen. + # Chosen in an archive rather than in the command line because this + # guard is about the whole tree: a mutation inside internal/cli would + # pass on a guard that only watched internal/cli, and that was the + # narrower guard worth refusing. + "a counted noun goes back to dodging the plural", + "internal/format/zip/zip.go", + 'fmt.Sprintf("%s of %d B", core.Count(g.Count, kind+" file", kind+" files"), g.Bytes)', + 'fmt.Sprintf("%d %s file(s) of %d B", g.Count, kind, g.Bytes)', + "TestNoMessageDodgesThePluralWithBrackets", + ), + ( + # The real defect, put back: the picture is drawn, committed, embedded + # and linked, and nobody gives it to the toolkit - so the desktop shows + # the toolkit's own logo and every check that reads the file on disk is + # green. The bytes stay referenced here, so this compiles and the + # linking half of the guard still passes. Only the half that asks + # whether anybody handed it over can see it. + "the icon is shipped and never given to the toolkit", + "internal/gui/run_cgo.go", + 'a.SetIcon(fyne.NewStaticResource("chickpea.png", icon.PNG))', + "_ = icon.PNG", + "TestTheWindowShipsTheIconWeDrew", + ), + ( + # The real defect, put back in the shape it would really arrive in: the + # row of buttons goes into a container that lays nothing out, so every + # button keeps its text, keeps Visible, keeps its handler - and gets no + # size. Every test in this package that presses by calling OnTapped + # stays green through that, because a handler does not care how big + # anything is. A press through the canvas has nothing to land on. + "the action bar stops laying its buttons out", + # Repointed 2026-09-23, when the row became parts.ButtonRow: its layout + # stops placing anything. + "internal/gui/parts/buttonrow.go", + "\t\to.Resize(min)\n\t\to.Move(fyne.NewPos(x, (size.Height-min.Height)/2))\n", + "\t\t_ = x\n", + "TestEveryButtonAPersonCanSeeIsReallyPressable", + ), + ( + # The pointer takes the keyboard and says so again, which is the state + # reported from the screen twice - a menu left painted blue after a + # value was chosen, with nothing left to press that would take it off. + "a press with the mouse draws the keyboard mark again", + "internal/gui/parts/ring.go", + "\tif !c.from.Draws() {\n\t\treturn\n\t}\n\tc.mark()", + "\tc.mark()", + "TestAPressMovesTheKeyboardWithoutDrawingItsMark", + ), + ( + # The other direction: nothing ever draws the mark, which looks tidy on + # every screenshot and breaks UX9 - where the keyboard is has to be + # visible or the rest of that rule buys nothing. + "the keyboard arrives and nothing on the control says so", + "internal/gui/parts/ring.go", + "func (c *Chooser) mark() {\n\tc.marked = true", + "func (c *Chooser) mark() {\n\tif true {\n\t\treturn\n\t}\n\tc.marked = true", + "TestTheKeyboardDrawsItsMarkWhenTheKeyboardIsWhatArrived", + ), + ( + # Somebody opens a list with the mouse, then drives it with the arrows, + # and from that moment they need to see which control is listening. + "reaching for the keyboard no longer turns the mark on", + "internal/gui/parts/ring.go", + "\tif !c.marked {\n\t\tc.mark()\n\t}\n\tswitch event.Name {", + "\tswitch event.Name {", + "TestReachingForTheKeyboardTurnsTheMarkOn", + ), + ( + # The switch built from the toolkit's own type keeps the blue disc on + # every press. This is the second place switches come from and the one + # nobody would remember, because no format declares a bool setting yet. + # + # The menu a declared choice produces put back as the toolkit's own, + # which fills itself in the focus colour when pressed and never takes it + # off. This is O131: property.go does not import widget, so the way to + # name the toolkit's control without a second change is the Select our + # Chooser embeds - &NewChooser(...).Select is a *widget.Select without + # naming its package. The guard that asks whether every menu is ours + # reads the type FromProperty returns and goes red. + # + # Since 2026-09-15 the switch this used to swap is our own with no Check + # to embed, so the handle moved from the bool field to the choice field + # - the guard proves both, by asking FromProperty for each kind. + "a declared menu is the toolkit's own again", + "internal/gui/parts/property.go", + "\tsel := NewChooser(p.Choices, nil)", + "\tsel := &NewChooser(p.Choices, nil).Select", + "TestEverySwitchAndMenuOnScreenKnowsWhoFocusedIt", + ), + ( + # Back to one refusal per press however many boxes are wrong, which is + # what was reported: settle giving up at the first bad value. + "the form gives up at the first bad box again", + "internal/gui/window/generate.go", + "\t\tbad = append(bad, saying(format.SettingSize, err))", + "\t\treturn nil, none, saying(format.SettingSize, err)", + "TestARefusalAboutTwoBoxesMarksBothOfThem", + ), + ( + # A box says nothing about what is in it until a button is pressed, + # which is the state this window was in until 2026-08-18. + "typing no longer checks what was typed", + "internal/gui/window/run.go", + "\tr.fields.WhenTypedIn(r.recheck)", + "\t_ = r.recheck", + "TestABadValueMarksItsBoxWithNothingPressed", + ), + ( + # Every refusal on the screen wiped at each keystroke, so a size the + # FORMAT refused goes unmarked the moment anything else is typed - and + # it is still refused. Found by the stored tree rather than by reading. + "a keystroke wipes what every other box was told", + "internal/gui/window/runrefuse.go", + "\tr.fields.Clear(setting)\n\tif r.fields.Blank(setting) {", + "\tr.fields.ClearAll()\n\tif r.fields.Blank(setting) {", + "TestTypingInOneBoxLeavesAnotherBoxesRefusalAlone", + ), + ( + # A box emptied to retype it turns red immediately, which is the + # commonest thing anybody does in a form. + "an empty box is called wrong while it is being retyped", + "internal/gui/window/runrefuse.go", + "\tif r.fields.Blank(setting) {\n\t\treturn\n\t}", + "\tif false {\n\t\treturn\n\t}", + "TestAnEmptyBoxIsNotCalledWrongUntilSomethingIsPressed", + ), + ( + # Repointed on 2026-09-08, in the evening. The version above belonged + # to gui/new-look, where the buttons sat at the right hand end - the + # branch is gone and so is that guard. On main they are centred, on + # the owner's decision of 2026-08-19, and centring takes a spacer at + # BOTH ends: one greedy spacer can only push the group to an end. + # Taking one away compiles, keeps the layout import used, and moves + # the pair off centre where nothing but the guard can see it. + "the run buttons are laid from the left edge rather than centred", + # Repointed 2026-09-23: the two spacers became parts.ButtonRow, which + # centres by working out where the row starts. Repointed 2026-09-24: + # the row gives way to the right of centre when the rail needs it. + "internal/gui/parts/buttonrow.go", + "\tx := fyne.Max((size.Width-b.MinSize(objects).Width)/2, b.clearLeft)\n", + "\tx := fyne.Max(float32(0)*size.Width, b.clearLeft)\n", + "TestTheButtonsThatRunSomethingSitInTheMiddle", + ), + ( + # The state the toolkit ships: widget.Select.TypedRune is left blank on + # purpose, so a letter at a closed list did nothing at all. + "a letter typed at a shut list does nothing again", + "internal/gui/parts/ring.go", + "\t\tif strings.HasPrefix(strings.ToLower(c.Options[at]), want) {\n\t\t\tc.SetSelectedIndex(at)", + "\t\tif strings.HasPrefix(strings.ToLower(c.Options[at]), want) {\n\t\t\t_ = at", + "TestALetterTypedAtTheShutListMovesToThatValue", + ), + ( + # The same letter twice stops walking, so the second of two values + # sharing a letter cannot be reached at all. + "the letter jump sticks on the first value that matches", + "internal/gui/parts/ring.go", + "\tfrom := c.SelectedIndex()", + "\tfrom := -1", + "TestALetterTypedAtTheShutListMovesToThatValue", + ), + ( + # PopUpMenu.TypedRune does nothing, which is where this started. + "a letter typed at the open list does nothing again", + "internal/gui/parts/openlist.go", + "\t\tif e := l.entries[at]; e.kind == entryValue && strings.HasPrefix(strings.ToLower(e.text), want) {\n\t\t\tl.moveTo(at)", + "\t\tif e := l.entries[at]; e.kind == entryValue && strings.HasPrefix(strings.ToLower(e.text), want) {\n\t\t\t_ = at", + "TestALetterTypedAtTheOpenListMovesTheKeyboard", + ), + ( + # A press paints a bar across the row the arrows start from, which says + # the keyboard is here to somebody who has just used a mouse. + "opening with a press paints the keyboard's row again", + "internal/gui/parts/openlist.go", + "\t\t\tl.moveTo(i)\n\t\t\tl.shown = false", + "\t\t\tl.moveTo(i)\n\t\t\tl.shown = true", + "TestOpeningAListWithAPressDrawsNoKeyboardBar", + ), + ( + # The ceiling goes and the list is as tall as it likes - 476 px at + # thirteen formats, 672 at the twenty-four there are, which is more + # than half of the reference window and all of a 650 px one. + "the open list loses its ceiling and covers the form", + "internal/gui/parts/ring.go", + "\tif rows > ceiling {\n", + "\tif rows > ceiling && false {\n", + "TestTheOpenListCoversHalfTheWindowAndGrowsWithIt", + ), + ( + # The ceiling stops following the window and is worked out for one + # window of 800 px whatever the window is - fourteen rows everywhere, + # which is the count of eight back under another name. Caught by the + # half that asks two canvases, and by the taller one leaving more than + # a row of its half unused. + "the open list's ceiling is the same in every window again", + "internal/gui/parts/openlist.go", + "rows := float32(math.Floor(float64(canvasHeight * listShare / row)))", + "rows := float32(math.Floor(float64(800 * listShare / row)))", + "TestTheOpenListCoversHalfTheWindowAndGrowsWithIt", + ), + ( + # Escape closes the list and leaves the keyboard on a control that is no + # longer on the screen, after which Tab starts from nowhere visible. + "Escape closes the list without giving the keyboard back", + "internal/gui/parts/ring.go", + "\tif byKeyboard {\n\t\tsurface.Focus(c)", + "\tif byKeyboard {\n\t\tsurface.Focus(nil)", + "TestEscapeClosesTheListAndGivesTheKeyboardBack", + ), + ( + # The room between rows comes back, which is the whole reason this + # control exists: widget.List spaced rows by the form's own padding. + # Since 2026-09-23 the rows are ours (rowStack), laid out one row apart. + "the rows are spaced by the form's padding again", + "internal/gui/parts/rowview.go", + "\t\to.Move(fyne.NewPos(0, float32(at)*row))", + "\t\to.Move(fyne.NewPos(0, float32(at)*(row+rowGap)))", + "TestEveryScreenStillDrawsItsStoredPicture", + ), + ( + # The rows go back under a theme override - a new scope and a fresh + # set of fonts at every opening, 158 MB for ten (GUI-MEMORY 4e). + "the open list draws its rows under a theme override again", + "internal/gui/parts/openlist.go", + "\trows := l.view.scroll\n", + "\trows := container.NewThemeOverride(l.view.scroll, Theme())\n", + "TestNoScreenStandsInAThemeOverride", + ), + ( + # Arrowing down past the room leaves the keyboard on a row nobody can + # see. No stored screen scrolls the list, so no picture would notice. + "the keyboard walks below the open list out of sight", + "internal/gui/parts/rowview.go", + "\t\tv.scroll.Offset.Y = top + row - v.scroll.Size().Height", + "\t\tv.scroll.Offset.Y += 0", + "TestTheRowTheKeyboardIsOnIsAlwaysInSight", + ), + ( + # An emptied filter in a list whose box holds no value finds nothing + # to start on, and the rows of what it had narrowed to stay drawn. + "an emptied filter leaves the narrowed rows drawn when nothing is chosen", + "internal/gui/parts/openlist.go", + "\t// narrowed to still drawn.\n\tl.view.show(len(l.entries), l.fill)", + "\t// narrowed to still drawn.\n\t_ = l.view", + "TestEmptyingTheFilterOfAListWithNothingChosenDrawsEveryRowAgain", + ), + ( + # Arrowing up to the first value of a kind stops showing the heading + # that says which kind it was. + "the first value of a kind comes into sight without its heading", + "internal/gui/parts/openlist.go", + "\t\tl.view.bringIntoView(at - 1)\n", + "\t\t_ = at\n", + "TestTheRowTheKeyboardIsOnIsAlwaysInSight", + ), + ( + # A spelling that can mean two things stops meaning the one this tool + # means everywhere else, which is the whole of the rule the owner set + # on 2026-08-18. + "a boundary spelled in units stops counting in 1024s", + "internal/core/size.go", + "func ParseBoundary(s string) (int64, error) {\n\treturn ParseSize(s)", + "func ParseBoundary(s string) (int64, error) {\n\treturn ParseSize(s + \"x\")", + "TestABoundarySpelledInUnitsIsTakenAsTheOneWeUseEverywhere", + ), + ( + # The run stops saying which number it built around. This is what makes + # accepting an ambiguous spelling safe: the rare reader whose system + # meant the other number sees it in a dry run, before a byte is written. + "a boundary run stops saying the number it built around", + "internal/cli/generate.go", + "\t\tfmt.Fprintf(errOut, \"boundary %q around %s:\\n\", t.ID, core.ExactBytes(t.BoundaryLimit))", + "\t\tfmt.Fprintf(errOut, \"boundary %q:\\n\", t.ID)", + "TestABoundaryRunSaysTheNumberItBuiltAround", + ), + ( + # The recipe key and the flag stop reading a spelling the same way, + # which is a recipe and a command line disagreeing about one word. + "the recipe boundary key reads units its own way", + "internal/recipe/target.go", + "\tlimit, err := core.ParseBoundary(text)", + "\tlimit, err := core.ParseBoundary(text + \"b\")", + "TestTheRecipeBoundaryKeyReadsUnitsLikeTheFlag", + ), + ( + # Nudged up until the rounding it absorbs is bigger than the smallest + # real change this window can make, which is the quiet way a tolerance + # turns a comparison into a formality. + "the arm64 blend tolerance grows until it can hide a change somebody meant to see", + "internal/guard/screenpixels_test.go", + "const armBlendTolerance = 6", + "const armBlendTolerance = 12", + "TestTheBlendToleranceStaysBetweenTheRoundingAndTheSmallestRealChange", + ), + ( + # And the other way, which is the half a one sided check would miss: a + # tolerance under the measured rounding makes arm64 red on every run for + # a difference nobody can see. + "the arm64 blend tolerance drops below the rounding it was measured to absorb", + "internal/guard/screenpixels_test.go", + "const armBlendTolerance = 6", + "const armBlendTolerance = 2", + "TestTheBlendToleranceStaysBetweenTheRoundingAndTheSmallestRealChange", + ), + ( + # The tolerance moves to amd64, which was measured at exactly zero - so + # it would give away accuracy this guard actually has, on the + # architecture that never needed it. + "the blend tolerance lands on the architecture that was measured not to need it", + "internal/guard/screenpixels_test.go", + "\tif runtime.GOARCH == \"arm64\" {\n\t\treturn armBlendTolerance\n\t}", + "\tif runtime.GOARCH != \"arm64\" {\n\t\treturn armBlendTolerance\n\t}", + "TestTheBlendToleranceStaysBetweenTheRoundingAndTheSmallestRealChange", + ), + ( + # Back to one line of evidence, which is how O115 stayed open for three + # days: the runner has no file to diff, so the first differing line was + # the whole of what anybody could see. + "a failing screen names only the first line that moved", + "internal/guard/screenpixels_test.go", + "\t\tif total <= cap {", + "\t\tif total <= 1 {", + "TestAFailingScreenNamesEveryLineThatMovedNotJustTheFirst", + ), + ( + # The report stops at its cap and says nothing about stopping, so a + # reader counts the lines it shows and believes that is all of them. + "the capped diff stops without saying anything was left out", + "internal/guard/screenpixels_test.go", + "\tif total > cap {", + "\tif false && total > cap {", + "TestAFailingScreenNamesEveryLineThatMovedNotJustTheFirst", + ), + ( + # Skipped on every system again, which is the state this was in until + # 2026-08-23 - a bramka that runs nowhere and reports green. + "the screens go back to being skipped on the whole of CI", + "internal/guard/screenpixels_test.go", + "\treturn goos != \"darwin\"", + "\treturn false", + "TestTheScreensAreComparedOnCIEverywhereButMacOS", + ), + ( + # Compared on macOS as well, where Fyne models the system setting for + # showing scroll bars and nine screens legitimately differ - so the + # comparison would be red for a reason that is not a defect. + "the screens are compared on macOS, where the system hides scroll bars", + "internal/guard/screenpixels_test.go", + "\treturn goos != \"darwin\"", + "\treturn true", + "TestTheScreensAreComparedOnCIEverywhereButMacOS", + ), + ( + # Back to asking the stop handle whether a run is going. The handle is + # assigned once and never returns to nil, so the live check died after + # the first Generate and a bad value said nothing until a button was + # pressed. + "the live check asks the stop handle whether a run is going", + "internal/gui/window/runrefuse.go", + "\tif r.busy.occupied {\n\t\treturn\n\t}", + "\tif r.stop != nil {\n\t\treturn\n\t}", + "TestTypingIsStillCheckedAfterARunHasFinished", + ), + ( + # The disk work goes back onto the interface thread, which is a window + # that stops drawing for as long as somebody else's disk takes - no bar, + # no way out, and both buttons still looking pressable. + "a preview reads the disk on the interface thread again", + "internal/gui/window/run.go", + "\tgo func() {\n\t\tplanned, planErr := engine.PlanContext(ctx, targets, opt)\n\t\tif planErr != nil {\n\t\t\t// Do rather", + "\tfunc() {\n\t\tplanned, planErr := engine.PlanContext(ctx, targets, opt)\n\t\tif planErr != nil {\n\t\t\t// Do rather", + "TestAPreviewDoesItsDiskWorkOffTheInterfaceThread", + ), + ( + # Nothing says the screen is occupied, so the form stays editable and + # both buttons stay pressable while a preview is reading the disk. + "a preview never says the screen is busy", + "internal/gui/window/run.go", + "\tr.busy.set(true, busyFace{stoppable: true})\n\tr.say(text.WorkingOutTheCost())", + "\tr.say(text.WorkingOutTheCost())", + "TestAPreviewDoesItsDiskWorkOffTheInterfaceThread", + ), + ( + # The screen is never handed back, so a finished preview leaves the form + # frozen and the only way out is to close the window. + "a preview never gives the screen back", + "internal/gui/window/run.go", + "\tr.busy.set(false, busyFace{})\n\tif runErr != nil {", + "\tif runErr != nil {", + "TestAPreviewGivesTheScreenBackWhenItIsDone", + ), + ( + # The screen is marked busy before the settings have been judged, so a + # preview that is refused straight away leaves the form frozen with a + # refusal on it and nothing to press. + "a preview freezes the screen before it knows it is refused", + "internal/gui/window/run.go", + "\tr.clearProblems()\n\ttargets, opt, err := r.settle()\n\tif err != nil {\n\t\tr.refuse(err)\n\t\treturn\n\t}\n\topt.DryRun = true", + "\tr.clearProblems()\n\tr.busy.set(true, busyFace{})\n\ttargets, opt, err := r.settle()\n\tif err != nil {\n\t\tr.refuse(err)\n\t\treturn\n\t}\n\topt.DryRun = true", + "TestARefusedPreviewLeavesTheScreenUsable", + ), + ( + # The count stops following the box, so it keeps saying what the value + # WAS. A stale number beside a box is worse than no number: the reader + # has no way to tell it is stale. + "the count of bytes stops following what is typed in the box", + "internal/gui/parts/fields.go", + "func(value string, into *ByteCount) { into.show(value) })", + "func(value string, into *ByteCount) {})", + "TestABoxHoldingASizeSaysWhatItComesTo", + ), + ( + # A count appears on every box whose value happens to parse as a size, + # which is the version this was deliberately not built as - "how many" + # holds 1 and would grow a caption reading "1 B". + "a count of bytes appears on boxes that do not hold a size", + "internal/gui/parts/fields.go", + " if !s.inBytes[setting] {\n return nil\n }", + " if false {\n return nil\n }", + "TestOnlyABoxHoldingASizeCarriesACount", + ), + ( + # Back to handing the engine the recipe reader's answer for an absent + # output directory, which is the current one - so clearing the box on + # the batch screen writes beside the window without a word. O125, and + # the same defect O102 fixed for the other two screens. + "an emptied output directory on the batch screen means the current one again", + "internal/gui/window/recipe.go", + " if typed == \"\" {\n return \"\"\n }\n return parsed", + " return parsed", + "TestClearingTheOutputDirectoryRefusesTheRunOnEveryScreen", + ), + ( + # A declared setting that holds a size stops saying what it comes to. + # This is the call the preset screen only started making on 2026-08-24 - + # before that it drew its parameters with a loop of its own and this had + # to be written twice. + "a declared setting that holds a size stops carrying its count", + "internal/gui/parts/property.go", + " if p.Kind == format.PropertySize {\n into.InBytes(p.Name)\n }", + " if false && p.Kind == format.PropertySize {\n into.InBytes(p.Name)\n }", + "TestADeclaredSizeSaysWhatItComesToOnEveryScreenThatDrawsOne", + ), + ( + # Back to the toolkit's default, which is 11 of 255 off a fill chosen + # for somebody else's palette - and it is the only mark left saying + # where a value goes. + "the mark round a box to type in goes back to the toolkit's own", + "internal/gui/parts/theme.go", + "\t\ttheme.ColorNameInputBorder: hex(0x55, 0x55, 0x5C),", + "\t\ttheme.ColorNameInputBorder: hex(0x39, 0x39, 0x3A),", + "TestABoxToTypeInWearsAMarkThatCanBeSeen", + ), + ( + # The star stops being drawn while every screen still declares which + # fields it cannot run without. Nothing else on any screen looks at + # this mark, so without a guard the whole of it could go and the form + # would read as if it never had one. + "the star saying a field must be filled in stops being drawn", + "internal/gui/parts/required.go", + "\tif required {\n\t\trow = append(row, newRequiredMark())", + "\tif required && false {\n\t\trow = append(row, newRequiredMark())", + "TestTheStarsDrawnAreTheOnesTheScreenDeclared", + ), + ( + # A setting the run refuses when it is empty stops being marked. This + # is the drift the declaration exists to risk, so it is the one worth + # proving the engine half catches: the box goes on refusing and the + # only way to find out is to press a button. + "a box the run will not do without stops carrying its star", + "internal/gui/window/generate.go", + "\tg.fields.Require(format.SettingSize, engine.SettingCount, engine.SettingID,", + "\tg.fields.Require(engine.SettingCount, engine.SettingID,", + "TestAStarIsOnEveryBoxTheRunWillNotDoWithout", + ), + ( + # And the other way round, which is the half a guard reading only the + # declaration would never see: a star on a field nobody has to fill in + # asks for work the tool does not need. + "a star appears on a box the run is happy to leave empty", + "internal/gui/window/generate.go", + "\t\tengine.SettingSeed, engine.SettingOutDir)", + "\t\tengine.SettingSeed, engine.SettingOutDir, engine.SettingName)", + "TestAStarIsOnEveryBoxTheRunWillNotDoWithout", + ), + ( + # A slot is machinery rather than a word. A refusal that carries one and + # is never asked to render it puts "{setting}" in front of somebody, and + # from the side that writes the sentence that is invisible - it compiles, + # it passes vet and it reads correctly in the source. + "a refusal keeps its slot instead of putting a name in it", + "internal/recipe/errors.go", + " return core.InTheWordsOf(", + " return (func(m, n string) string { return m })(", + "TestNoRefusalEverShowsASlotToAPerson", + ), + ( + # An empty name is what a field with no label asks for. Answering it with + # the sentence untouched leaves the slots standing, which is the one + # thing a slot must never do. + "a refusal with nobody to name it leaves the slot showing", + "internal/recipe/errors.go", + " if name == \"\" {\n name = core.LastSettingSegment(p.At)\n }", + " if false && name == \"\" {\n name = core.LastSettingSegment(p.At)\n }", + "TestNoRefusalEverShowsASlotToAPerson", + ), + ( + # The article rule answers from the first written letter, which is right + # for every name either surface uses and is not the whole of English. + # Losing a vowel gives "a id", which reads as a typo rather than as a + # mechanism coming apart. + "the article in front of a setting stops matching the name after it", + "internal/core/setting.go", + " case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U':", + " case 'a', 'e', 'o', 'u', 'A', 'E', 'O', 'U':", + "TestEveryNameARefusalCanBeGivenTakesTheArticleThisRuleGivesIt", + ), + ( + # Every word became a lookup, and from inside the build that is + # invisible: the English is still written beside each entry and answers + # when no catalogue is loaded. A seam that stops asking looks exactly + # right in English and can never be translated. + "the window stops asking the catalogue and answers in English forever", + "internal/gui/text/catalogue.go", + "\t\treturn pseudoOf(english)\n\t}\n\tif localiser == nil {", + "\t\treturn pseudoOf(english)\n\t}\n\tif true || localiser == nil {", + "TestTheWindowSpeaksWhateverTheCatalogueSays", + ), + ( + # English is written in the code and copied into en.json for translators. + # Two places holding one sentence, and only this guard between them - a + # message changed on one side leaves a translator working from the other. + "the English in the code walks away from the English a translator is given", + "internal/gui/text/screens.go", + 'func FieldSize() string { return say("FieldSize", "Size") }', + 'func FieldSize() string { return say("FieldSize", "Size of each file") }', + "TestTheEnglishCatalogueSaysWhatTheCodeSays", + ), + ( + # D17 is a flat hyphen and no semicolons, and PRODUCT.md puts translation + # files in its scope. The punctuation guard covers neither internal/gui + # nor any JSON, so on the day the first catalogue appeared the rule lost + # its guard exactly where the document declares it applies. + "a translation takes a dash D17 does not allow", + "internal/gui/text/locale/en.json", + "The tool is free and stays free - this pays", + "The tool is free and stays free – this pays", + "TestEveryTranslationObeysThePunctuationRule", + ), + ( + # A folded batch with no line under its name is a column of headings + # somebody opens one by one to find the one they meant, which is a worse + # screen than a long one. + "a folded batch says nothing about what is in it", + "internal/gui/window/recipefolds.go", + " fold.Say(say())", + " fold.Say(\"\")", + "TestAFoldedBatchSaysWhatIsInIt", + ), + ( + # The whole reason putting batches away was rejected on 2026-08-18: a + # batch that is not on the screen has no box to mark, so the form + # refuses to run and marks nothing anybody can see. + "a refusal leaves the batch it is about folded shut", + "internal/gui/window/recipe.go", + " r.runner.unfold = r.openFoldHolding", + " r.runner.unfold = nil", + "TestARefusalOpensTheBatchItIsAbout", + ), + ( + # Copying is what makes several batches quick to write, and batches + # usually differ in one setting. A copy that arrives empty is a form + # somebody types out again. + "copying a batch copies nothing but the batch", + "internal/gui/window/recipe.go", + " to.count.SetText(from.count.Text)", + " to.count.SetText(\"\")", + "TestACopiedBatchCarriesEverythingButItsName", + ), + ( + # The switch is built with the batch rather than with the panel that + # draws it, so a copy can read it. Built later, a copy took the values + # of the other two ways and then showed the first one. + "a copied batch forgets which way it states its size", + "internal/gui/window/recipe.go", + " if from.sizeWay != nil && to.sizeWay != nil {", + " if false && from.sizeWay != nil && to.sizeWay != nil {", + "TestACopiedBatchCarriesEverythingButItsName", + ), +( + # A refusal about a box two floors down has to open both. Opening only the + # batch is the defect of 2026-08-18 one floor lower: the screen refuses to + # run and marks a box nobody can see. + "a refusal opens the batch but not the section inside it", + "internal/gui/window/recipefolds.go", + "for _, fold := range []*parts.Folding{b.fold, b.settings, b.notes.fold} {", + "for _, fold := range []*parts.Folding{b.fold} {", + "TestARefusalOpensTheSectionTheBoxIsIn", +), +( + # The section is named for where its settings go. One that carried nothing + # to the manifest would be a fold hiding boxes with no effect anywhere. + "the kind of case stops reaching the manifest", + "internal/gui/window/recipe.go", + "Group: b.notes.group.Text,", + "Group: \"\",", + "TestNothingInTheManifestNotesChangesAByte", +), +( + # A section that arrives folded and swallows what somebody typed is a + # setting they cannot see and did not remove. + "a folded section says nothing about the notes in it", + "internal/gui/window/recipefolds.go", + "return text.FoldedSummary(n.group.Text, n.purpose.Text, n.expected.Selected, n.reason.Selected)", + "return \"\"", + "TestASectionPutAwayStillSaysWhatIsInIt", +), +( + # The owner's decision of 2026-08-25: both sections arrive put away, which + # is 248 px of form per screen. + "the settings of a batch arrive open instead of folded", + "internal/gui/window/recipe.go", + "settingsFolded: true,", + "settingsFolded: false,", + "TestTheSectionsInsideABatchArriveFolded", +), +( + # Until 2026-08-25 the declaration's verdict arrived a layer up, where a + # target is an entry in a list rather than targets[2] - so a mistyped width + # had no address in validate --json and nothing for a form to mark. + "the recipe stops asking the format about the values it was given", + "internal/recipe/target.go", + "\tif len(stated) == 0 {\n\t\treturn\n\t}", + "\tif len(stated) >= 0 {\n\t\treturn\n\t}", + "TestEveryRecipeRefusalSaysWhichSettingItIsAbout", +), +( + # A refusal with an empty reason prints as a bare dash, which is what the + # first version of the property check did on 2026-08-25 and what nothing + # caught until this guard asked every case rather than one recipe. + "a refusal about a property loses the reason behind it", + "internal/recipe/target.go", + "unknown.Why(), unknown.Instead())", + "\"\", unknown.Instead())", + "TestEveryRecipeRefusalSaysWhichSettingItIsAbout", +), +( + # A box drops a shortcut it does not know without a word, so a shortcut + # registered anywhere else never arrives while somebody is typing - which + # is exactly when they would press Ctrl+Enter. + "a box swallows the window's own shortcuts instead of passing them on", + "internal/gui/parts/entry.go", + "if _, ours := shortcut.(*desktop.CustomShortcut); ours && e.onOurs != nil {", + "if _, ours := shortcut.(*desktop.CustomShortcut); ours && e.onOurs != nil && false {", + "TestTheKeyboardStartsARunFromInsideABox", +), +( + # Ctrl+P is a separate entry in the table from Ctrl+Enter, so breaking one + # says nothing about the other. + "the preview shortcut is not wired to anything", + "internal/gui/window/open.go", + "\t\tkeyboardScreen.PressPreview)", + "\t\tfunc(keyboardScreen) {})", + "TestThePreviewShortcutAsksWithoutWriting", +), +( + # Owner's decision of 2026-08-25: the keyboard starts on the first field of + # the screen being looked at, so a person can type without reaching for the + # mouse first. + "the keyboard starts nowhere when a screen opens", + "internal/gui/window/open.go", + "\t\tparts.FocusQuietly(h.Canvas(), first)", + "\t\t_ = first", + "TestTheKeyboardStartsOnTheFirstFieldOfTheScreen", +), +( + # A finished run offers the folder it wrote into - point 20 of the rebuild + # and the last live part of O85. + "a finished run does not offer the folder it wrote into", + "internal/gui/window/runoffers.go", + "\to.folderBtn.Show()", + "\to.folderBtn.Hide()", + "TestAFinishedRunOffersTheFolderItWroteInto", +), +( + # The offer belongs to one run. Left standing it points at the results of + # the run before this one, and somebody would open it and believe them. + "the folder offer keeps pointing at the run before this one", + "internal/gui/window/run.go", + "\tr.offer.wroteInto = opt.OutDir", + "\t_ = opt.OutDir", + "TestTheFolderOfferGoesAwayWhenTheNextRunStarts", +), +( + # A shortcut that worked on a hidden button would reach a state the screen + # does not offer - Escape cancelling a run that is not going. + "a shortcut presses a button the screen has taken out of use", + "internal/gui/window/runactions.go", + "if b == nil || b.Disabled() || !b.Visible() || b.OnTapped == nil {", + "if b == nil || b.OnTapped == nil {", + "TestAShortcutCannotStartASecondRunDuringTheFirst", +), +( + # Escape has to be harmless while nothing is running. Broken the other way + # round - by making it start a run instead of stopping one - because a + # guard about the ABSENCE of an effect cannot be reddened by removing one. + "Escape starts a run instead of stopping one", + "internal/gui/window/open.go", + "wire(&desktop.CustomShortcut{KeyName: fyne.KeyEscape}, keyboardScreen.PressCancel)", + "wire(&desktop.CustomShortcut{KeyName: fyne.KeyEscape}, keyboardScreen.PressGenerate)", + "TestEscapeIsOnlyLiveWhileThereIsSomethingToStop", +), + ( + "a menu goes back to taking the whole column, so it is the same shape as a box to type in", + "internal/gui/parts/field.go", + " if menu, is := control.(*Chooser); is {\n return Menu(menu)\n }", + " if menu, is := control.(*Chooser); is && false {\n return Menu(menu)\n }", + "TestAMenuIsNotDrawnAsWideAsABoxToTypeIn", + ), + ( + "a menu draws no line when nobody is using it, so it has no edge of its own again", + "internal/gui/parts/ring.go", + " case r.resting != nil:", + " case r.resting != nil && false:", + "TestTheMenuTheKeyboardIsInDrawsALine", + ), + ( + "the directory the window was last told to use is not offered when it opens", + "internal/gui/window/open.go", + " if last == \"\" || LeftByTheOldOffer(last, programDirectory()) {", + " if last == \"\" || LeftByTheOldOffer(last, programDirectory()) || true {", + "TestTheWindowOffersTheDirectoryItWasLastToldToUse", + ), + ( + "closing the window forgets where the files were going", + "internal/gui/window/open.go", + " if screen, ok := working[*showing]; ok {\n h.Remembered().RememberDirectory(screen.OutDir())\n }", + " if screen, ok := working[*showing]; ok && false {\n h.Remembered().RememberDirectory(screen.OutDir())\n }", + "TestClosingTheWindowRemembersWhereTheFilesWereGoing", + ), + ( + "a first start offers the working directory itself instead of a folder of ours", + "internal/gui/window/offered.go", + " return filepath.Join(working, OutputFolderName)", + " return filepath.Join(working, \"\")", + "TestAFirstStartOffersTheFolderItAlwaysDid", + ), + ( + "a remembered window size is centred anyway, which puts a big one out of reach", + "internal/gui/window/opening.go", + " return remembered, false", + " return remembered, true", + "TestARememberedSizeIsUsedAndNotRecentred", + ), + ( + "a first start is not centred, so it lands wherever the system chose", + "internal/gui/window/opening.go", + " return size, true", + " return size, false", + "TestAFirstStartOpensAsTallAsTheScreensWantInTheMiddle", + ), + ( + "a size with a nought in it counts as a size, so a minimised window comes back as nothing", + "internal/gui/window/remembered.go", + " return size.Width > 0 && size.Height > 0", + " return size.Width >= 0 && size.Height >= 0", + "TestASizeWithANoughtInItIsNotWorthRemembering", + ), + ( + "a doc comment opens by naming a different exported declaration of the same package", + "internal/core/size.go", + "// BoundarySizes turns a limit into the three sizes a boundary set means: one", + "// ParseSizeRange turns a limit into the three sizes a boundary set means: one", + "TestNoDocCommentOpensByNamingADifferentDeclaration", + ), + ( + "the shape this guard was written for: a paragraph about Plan left sitting above an unexported helper", + "internal/engine/engine.go", + "// settleTarget checks everything that has to be true about one target before", + "// Plan checks everything that has to be true about one target before", + "TestNoDocCommentOpensByNamingADifferentDeclaration", + ), + ( + "the rule about characters the host cannot store stops being asked", + "internal/engine/filename.go", + "\tcase firstForbidden(name) != 0:", + "\tcase false && firstForbidden(name) != 0:", + "TestANameTheHostCannotStoreIsRefusedBeforeAnythingIsWritten", + ), + ( + "only the control character half of the name rule stops being asked", + "internal/engine/filename.go", + "\t\tif r < 0x20 || strings.ContainsRune(forbiddenChars, r) {", + "\t\tif r < 0x20 && false || strings.ContainsRune(forbiddenChars, r) {", + "TestANameTheHostCannotStoreIsRefusedBeforeAnythingIsWritten", + ), + ( + "the name rule grows to refuse a character the host stores perfectly well", + "internal/engine/filename.go", + 'const forbiddenChars = `<>"|?*`', + 'const forbiddenChars = `<>"|?*-`', + "TestAnOrdinaryNameIsStillAccepted", + ), + ( + "progress stops being serialised, so two reports run at once", + "internal/engine/parallel.go", + "\tg.report(Progress{", + "\tgo g.report(Progress{", + "TestNoTwoProgressReportsArriveAtOnce", + ), + ( + "a writer tops the bar up by the whole file instead of what it had left to say", + "internal/engine/parallel.go", + "\t\tp.gate.finished(plan.Bytes-p.reported, plan.Bytes+plan.Work-p.worked)", + "\t\tp.gate.finished(plan.Bytes, plan.Bytes+plan.Work-p.worked)", + "TestProgressOverSeveralWritersStillReachesTheEndAndNeverGoesBack", + ), + ( + "a stopped run goes back to recording a prefix, so a file that finished is left unnamed", + "internal/engine/engine.go", + "\t\tcase r.ok:", + "\t\tcase r.ok && stopped == nil:", + "TestARunStoppedPartWayNamesEveryFileThatFinished", + ), + ( + "the null device stops being refused, so a run records a file that went nowhere", + "internal/engine/filename.go", + '\tcase strings.EqualFold(name, "nul"):', + '\tcase false && strings.EqualFold(name, "nul"):', + "TestANameTheHostCannotStoreIsRefusedBeforeAnythingIsWritten", + ), + ( + "the device rule grows into the folklore version, refusing nul.txt as well", + "internal/engine/filename.go", + '\tcase strings.EqualFold(name, "nul"):', + '\tcase strings.HasPrefix(strings.ToLower(name), "nul"):', + "TestAnOrdinaryNameIsStillAccepted", + ), + ( + "a release grows a second heading for one kind of change", + "CHANGELOG.md", + "## [0.2.0] - 2026-08-28\n\n### Breaking", + "## [0.2.0] - 2026-08-28\n\n### Breaking\n\n### Breaking", + "TestTheChangelogHasOneHeadingPerKindOfChange", + ), + ( + "a kind of change nobody can filter on is invented", + "CHANGELOG.md", + "## [0.2.0] - 2026-08-28\n\n### Breaking", + "## [0.2.0] - 2026-08-28\n\n### Broken", + "TestTheChangelogHasOneHeadingPerKindOfChange", + ), + ( + "the ceiling refusal stops naming the batch that caused it", + "internal/engine/engine.go", + "\t\t\t\tSetting: core.TargetAddress(i+1, SettingCount),", + '\t\t\t\tSetting: "",', + "TestARefusalFromTheEngineAboutTheWholeRunMarksTheBatchThatCausedIt", + ), + ( + "the ceiling refusal names the batch before the one that caused it", + "internal/engine/engine.go", + "\t\t\t\tSetting: core.TargetAddress(i+1, SettingCount),", + "\t\t\t\tSetting: core.TargetAddress(i, SettingCount),", + "TestARefusalFromTheEngineAboutTheWholeRunMarksTheBatchThatCausedIt", + ), + ( + "the report stops carrying the address of a refusal from below the reader", + "internal/cli/recipecmd.go", + "entry := validateProblem{What: err.Error(), At: addressOf(err)}", + "entry := validateProblem{What: err.Error()}", + "TestEveryAddressTheReportCarriesSaysWhereItIs", + ), + ( + "an address that names no target starts being cut like one", + "internal/core/setting.go", + '\treturn strings.HasPrefix(address, KeyTargets+"[")', + "\treturn true", + "TestARefusalAboutTheRunItselfKeepsItsWholeAddress", + ), + ( + "a refusal from below stops carrying the position of its target", + "internal/engine/errors.go", + "\treturn &addressedError{err: err, at: core.TargetAddress(position, setting)}", + "\treturn err", + "TestOneRefusalMarksItsBoxOnBothScreens", + ), + ( + "the single batch screen stops dropping the position it is handed", + "internal/gui/window/runrefuse.go", + "\treturn core.LastSettingSegment(address)", + "\treturn address", + "TestOneRefusalMarksItsBoxOnBothScreens", + ), + ( + "the manifest name goes back to calling itself a file name", + "internal/engine/engine.go", + 'checkFileName(SettingOutputManifest, "the manifest", opt.ManifestName)', + 'checkFileName(SettingName, "the manifest", opt.ManifestName)', + "TestARefusalAboutTheManifestNameMarksTheManifestBox", + ), + ( + "validate stops looking at the name of the manifest", + "internal/cli/recipecmd.go", + "\t\tManifestName: rec.Output.Manifest,", + "", + "TestValidateRefusesWhatGenerateWouldRefuse", + ), + ( + "a refusal from below stops handing out its reason", + "internal/format/format.go", + "func (e *BelowMinimumError) Why() string { return e.Reason }", + 'func (e *BelowMinimumError) Why() string { return "" }', + "TestARefusalFromBelowTheReaderArrivesInItsThreeParts", + ), + ( + "the report stops asking a refusal for its parts", + "internal/cli/recipecmd.go", + "\tif errors.As(err, &parted) {", + "\tif false && errors.As(err, &parted) {", + "TestARefusalFromBelowTheReaderArrivesInItsThreeParts", + ), + ( + "the parts a refusal hands out come apart from the sentence it prints", + "internal/format/format.go", + 'func (e *NotAContainerError) Instead() string { return "Drop contains, or change the format" }', + 'func (e *NotAContainerError) Instead() string { return "Change the format" }', + "TestARefusalFromBelowTheReaderArrivesInItsThreeParts", + ), + ( + "a paragraph comes adrift onto an unexported declaration below it", + "internal/cli/verify.go", + "// verify compares a directory against a manifest an earlier run wrote.", + "// describeError compares a directory against a manifest an earlier run wrote.", + "TestNoDocCommentOpensByNamingADifferentDeclaration", + ), + ( + "the engine stops joining the remedy into the sentence it prints", + "internal/engine/errors.go", + '\t\tmessage += ". " + e.Remedy', + '\t\tmessage += ""', + "TestARefusalFromBelowTheReaderArrivesInItsThreeParts", + ), + ( + "a refusal of the engine stops saying why the rule is there", + "internal/engine/filename.go", + '\t\t\tBecause: "Windows refuses that character in a file name, so the file is not written there at all. It is refused on every system so that a recipe means one thing everywhere",', + '\t\t\tBecause: "",', + "TestARefusalFromBelowTheReaderArrivesInItsThreeParts", + ), + ( + "the manifest stops holding its own name, so a target can take it", + "internal/engine/engine.go", + "names[collisionKey(manifestNameOf(opt))] = nameOwner{", + 'names[collisionKey("")] = nameOwner{', + "TestNoTargetCanTakeTheNameTheManifestNeeds", + ), + ( + "only a manifest somebody named is held, so the default one is open again", + "internal/engine/engine.go", + "names[collisionKey(manifestNameOf(opt))] = nameOwner{", + "names[collisionKey(opt.ManifestName)] = nameOwner{", + "TestNoTargetCanTakeTheNameTheManifestNeeds", + ), + ( + "the manifest collision goes back to reading like two targets, with no address", + "internal/engine/engine.go", + "name: manifestNameOf(opt), manifest: true}", + "name: manifestNameOf(opt), manifest: false}", + "TestNoTargetCanTakeTheNameTheManifestNeeds", + ), + ( + "verify goes back to comparing a manifest path as it was written", + "internal/audit/audit.go", + "seen[comparablePath(f.Path)] = true", + "seen[f.Path] = true", + "TestVerifyMatchesAPathSpelledTheLongWayRound", + ), + ( + "the copy replacing somebody else's file stops reaching the disk", + "internal/core/replace.go", + "if err := f.Sync(); err != nil {", + "if err := error(nil); err != nil {", + "TestWhatIsRenamedIntoPlaceIsOnTheDiskFirst", + ), + ( + "a report that could not be written goes back to being a success", + "internal/cli/cli.go", + "if err := enc.Encode(v); err != nil {", + "if err := enc.Encode(v); err != nil && false {", + "TestAReportThatCouldNotBeWrittenWholeIsNotASuccess", + ), + ( + "the exit code of a broken report stops being an IO failure", + "internal/cli/cli.go", + "if code == ExitOK {", + "if code == ExitOK && false {", + "TestAReportThatCouldNotBeWrittenWholeIsNotASuccess", + ), + ( + "a recipe past the ceiling goes back to being a fault of the tool", + "internal/cli/errors.go", + "if errors.As(err, &recipeTooLarge) {", + "if errors.As(err, &recipeTooLarge) && false {", + "TestARecipeTooLargeToBeWrittenByHandIsRefused", + ), + ( + "the recipe reader stops refusing what it could not read whole", + "internal/cli/recipecmd.go", + "if int64(len(src)) <= recipe.MaxBytes {", + "if len(src) >= 0 {", + "TestARecipeTooLargeToBeWrittenByHandIsRefused", + ), + ( + "a boundary limit stops being refused at the low end", + "internal/core/size.go", + "if limit < 1 {", + "if limit < 1 && false {", + "TestABoundaryLimitIsRefusedAtBothEnds", + ), + ( + "the low end of a boundary is refused only at zero, not below it", + "internal/core/size.go", + "if limit < 1 {", + "if limit == 0 {", + "TestABoundaryLimitIsRefusedAtBothEnds", + ), + ( + "the screen wires its fields for typing a second time", + "internal/gui/parts/fields.go", + "\ts.tell = tell\n}", + "\ts.tell = tell\n\tfor _, f := range s.list {\n\t\ts.listen(f.Setting, f.Control)\n\t}\n}", + "TestAFieldWiredBeforeTheScreenListensReportsOnce", + ), + ( + "the walk of the directory stops asking whether it was cancelled", + "internal/audit/walk.go", + "\t\tif err := ctx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif d.IsDir() {", + "\t\tif d.IsDir() {", + "TestACancelledVerifyStopsInTheWalkToo", + ), + ( + "the refusal for too many files names a number the rule does not use", + "internal/core/limits.go", + "that is what lets a run that cannot succeed be refused before the first byte\", MaxFilesPerRun)", + "that is what lets a run that cannot succeed be refused before the first byte\", MaxFilesPerRun+1)", + "TestTheRefusalForTooManyFilesNamesTheCeilingItEnforces", + ), + ( + "the buffered write is never flushed, so the file on disk is short", + "internal/engine/parallel.go", + "\tif writeErr == nil {\n\t\twriteErr = buffered.Flush()\n\t}\n", + "", + "TestTheFileOnTheDiskIsTheSizeThatWasOrdered", + ), + ( + "a second file introduces the package as well", + "internal/cli/cleanup.go", + "// Part of package cli. See cli.go.", + "// Package cli is the command line.", + "TestNoPackageIntroducesItselfTwice", + ), + ( + # Both sentences describing a setting come from the declaration, and + # for size they used to be written out separately - so what a person + # read depended on whether they had already typed something wrong. O64. + "a size setting goes back to being described two ways", + "internal/format/format.go", + 'return "it takes " + sizePhrase', + 'return "it takes a size written the way any size is, such as 2mb or a plain byte count"', + "TestASettingIsDescribedTheSameWayEmptyOrRefused", + ), + ( + # The way out this refusal used to offer is one the window refuses: + # leaving the directory out is exactly what the person just did. O125. + "the empty directory refusal offers leaving it out again", + "internal/engine/engine.go", + 'Remedy: "Name a directory, for example ./fixtures"}', + 'Remedy: "Name a directory, for example ./fixtures, or leave it out to use the current one"}', + "TestTheEmptyDirectoryRefusalDoesNotOfferAWayOutThatIsRefused", + ), + ( + # A recipe naming both spellings writes two files on Windows and ONE on + # macOS - measured on a Mac Mini, see engine/names.go and + # tools/probes/apfs-case.py. That is the split D10 exists to close. + # + # 🔴 The replacement carries "_ = cases.Fold" and that line is the whole + # point of it. The faithful revert is the lowercasing that was here + # before, and on its own it leaves the cases import unused, so the tree + # stops compiling and the runner answers BROKEN - a verdict that proves + # nothing about a guard that is fine. That is O131 exactly, walked into + # again on the day it was fixed. The blank assignment keeps the import + # alive so the behaviour is the only thing that changes. + "two names that are one file on macOS go back to being two names here", + "internal/core/foldname.go", + "func FoldName(name string) string {\n\treturn cases.Fold().String(norm.NFC.String(name))\n}", + "func FoldName(name string) string {\n\t_ = cases.Fold\n\treturn norm.NFC.String(name)\n}", + "TestTwoNamesThatFoldTogetherAreRefused", + ), + ( + # strings.EqualFold folds SIMPLY, which puts the long s in the same + # orbit as s - so "maÅ¿s.txt" against "mass.txt" was answered with "they + # differ only in case", about two letters that are plainly not one + # letter in two sizes. A guard caught it on 2026-08-26, the day the + # sentence was written. + "two different letters are called a difference of case again", + "internal/engine/names.go", + "case strings.ToLower(norm.NFC.String(owner.name)) == strings.ToLower(norm.NFC.String(name)):", + "case strings.EqualFold(owner.name, name):", + "TestTwoNamesThatFoldTogetherAreRefused", + ), + ( + # gen-locale.py writes the catalogue from the calls it can see, so a + # sentence that states itself is simply absent from en.json - and a + # translator copying that file is handed an incomplete one with nothing + # to say so. Eighteen sentences were in that state until 2026-08-26. + # O130. + "a sentence in the window stops going through the catalogue", + "internal/gui/text/text.go", + "separator + say(\"PreviewNothingWritten\", \"nothing written yet\")", + "separator + \"nothing written yet\"", + "TestEverySentenceInTheTextPackageGoesThroughTheCatalogue", + ), + ( + # A plural hands over BOTH English forms, so a translation file may + # carry as many as its language needs - Polish has three. Sending one + # sentence with the number in it instead would put a thing into the + # catalogue that is broken for Polish before anybody starts, and it + # would look complete. + "a plural stops offering both English forms to a translator", + "internal/gui/text/text.go", + "return sayN(\"Files\", \"1 file\", \"{{.Count}} files\", n, nil)", + "return sayf(\"Files\", \"{{.Count}} files\", map[string]any{\"Count\": n})", + "TestTheEnglishCatalogueSaysWhatTheCodeSays", + ), + ( + "the site stops giving each language its own address, so two languages write to one page", + "internal/site/render.go", + 'if l.Dir != "" {', + 'if false && l.Dir != "" {', + "TestTheSiteSaysWhatTheToolSays", + ), + ( + "the download table stops offering the Windows build for ARM that the release actually produces", + "internal/guard/site_test.go", + '{System: "Windows", CLI: []string{"amd64", "arm64"}, GUI: []string{"amd64"}},', + '{System: "Windows", CLI: []string{"amd64"}, GUI: []string{"amd64"}},', + "TestTheSiteOffersEveryBinaryTheReleaseBuilds", + ), + ( + "a unit the registry counts in loses its Polish word, so that column would read as English on a Polish page", + "web/content/pl/site.json", + '"pixels": "pikseli",', + '"pixelsGone": "pikseli",', + "TestEveryLanguageDescribesEverythingTheProgramCanProduce", + ), + ( + "a page loses its twin in the other language, so the language switch on it would lead nowhere", + "web/content/pl/site.json", + '"key": "faq",', + '"key": "faq-orphan",', + "TestEveryPageExistsInEveryLanguage", + ), + ( + "Polish leaks onto an English page, which is the border the site rule stands on", + "web/content/en/site.json", + '"navLabel": "Main",', + '"navLabel": "Główna",', + "TestTranslatedTextStaysOnTheTranslatedPages", + ), + ( + "an English page picks up a semicolon, which rule 13 does not allow anywhere a user reads", + "web/content/en/index.html", + "Command line and desktop window,", + "Command line and desktop window;", + "TestTheSiteFollowsThePunctuationRule", + ), + ( + "a link on the busiest page points at a page that is not published", + "web/content/en/index.html", + 'documentation', + 'documentation', + "TestEveryLinkOnTheSiteLeadsSomewhere", + ), + ( + "the WAV ceiling goes away, so a file past four gigabytes states a wrapped length", + "internal/format/wav/wav.go", + "\tif r.Bytes > maxFileBytes {", + "\tif false && r.Bytes > maxFileBytes {", + "TestAFormatRefusesASizeItCannotDescribe", + ), + ( + "a refusal about a size that is too large goes back to saying the file is too small", + "internal/format/format.go", + '\treturn fmt.Sprintf("%s cannot be larger than %d B - %s. Requested: %d B. %s",', + '\treturn fmt.Sprintf("%s cannot be smaller than %d B - %s. Requested: %d B. %s",', + "TestARefusalAboutTooLargeSaysLargerNotSmaller", + ), + ( + "one container declares a shared setting its own way instead of the shared way", + "internal/format/zip/zip.go", + "\t\tProperties: archive.Axes(archive.Entries, archive.EntryFormat, archive.EntrySize,\n\t\t\tarchive.Compression, archive.Depth, archive.DirectoryEntries,\n\t\t\tarchive.Password, archive.Encryption),", + '\t\tProperties: append(archive.Axes(archive.Entries, archive.EntryFormat,\n\t\t\tarchive.Compression, archive.Depth, archive.DirectoryEntries,\n\t\t\tarchive.Password, archive.Encryption), ''format.Property{Name: archive.EntrySize, Kind: format.PropertySize, Default: "4kb"}),', + "TestEveryContainerDeclaresTheSharedAxesAsTheyAreDeclaredOnce", + ), + ( + "a picture format counts its width in a unit of its own", + "internal/format/webp/webp.go", + '\t\t\timagedim.Width(imagedim.Side{Largest: maxDimension,\n\t\t\t\tDetail: "How wide the picture is. Left out, the picture is sized to fill the bytes you asked for."}),', + '\t\t\tformat.Property{Name: imagedim.SettingWidth, Kind: format.PropertyInt,\n\t\t\t\tMin: imagedim.Smallest, Max: maxDimension, Unit: "px",\n\t\t\t\tDetail: "How wide the picture is. Left out, the picture is sized to fill the bytes you asked for."},', + "TestOneSettingNameMeansOneKindOfSetting", + ), + ( + "a tabular format stops offering more columns than a spreadsheet accepts", + "internal/format/xlsx/xlsx.go", + "\tmaxColumns = 32768", + "\tmaxColumns = 64", + "TestAFormatWithColumnsReachesPastWhatASpreadsheetAccepts", + ), + ( + "the backstop under the picture formats stops checking the range", + "internal/format/imagedim/imagedim.go", + "\tif n < Smallest || n > largest {", + "\tif (n < Smallest || n > largest) && false {", + "TestThePictureSideBackstopRefusesWhatTheDeclarationWould", + ), + ( + "the backstop starts changing the side it was given", + "internal/format/imagedim/imagedim.go", + "\treturn n, nil", + "\treturn n + 1, nil", + "TestASideInsideTheRangeIsTakenAsWritten", + ), + ( + "a text format declares the encoding setting its own way instead of the shared way", + "internal/format/md/md.go", + "\t\tProperties: textenc.Axes(textenc.Setting, textenc.SettingBOM),", + "\t\tProperties: []format.Property{\n\t\t\t{Name: textenc.Setting, Kind: format.PropertyChoice,\n\t\t\t\tChoices: []string{textenc.UTF16LE, textenc.UTF8}, Default: textenc.UTF8,\n\t\t\t\tDetail: \"Which encoding the characters are written in.\"},\n\t\t\ttextenc.Axes(textenc.SettingBOM)[0],\n\t\t},", + "TestEveryTextFormatDeclaresTheEncodingSettingsAsTheyAreDeclaredOnce", + ), + ( + "zero-head stops zeroing the bytes it says it zeroes", + "internal/damage/zerohead.go", + "\t\tz.buf[i] = 0", + "\t\tz.buf[i] = z.buf[i]", + "TestZeroHeadZeroesTheBytesItSaysAndNoOthers", + ), + ( + "a damage claims it changed something whatever the bytes were", + "internal/damage/zerohead.go", + "\t\tif b != 0 {", + "\t\tif b != 0 || true {", + "TestADamageThatMovedNothingSaysSo", + ), + ( + "the damage floor stops following the settings and answers with the default", + "internal/damage/zerohead.go", + "\t\tFloor: func(v Values) int64 { return int64(headBytes(v)) },", + "\t\tFloor: func(v Values) int64 { return defaultHead },", + "TestTheFloorFollowsThePairOfSettings", + ), + ( + "a chain applies its damages in the order it was built rather than listed", + "internal/damage/apply.go", + "\tfor i := len(c) - 1; i >= 0; i-- {", + "\tfor i := 0; i < len(c); i++ {", + "TestAChainAppliesItsDamagesInTheOrderItListsThem", + ), + ( + "a chain stops noticing that one of its damages was idle", + "internal/damage/apply.go", + "\t\tif i < len(c) && !s.Touched() {", + "\t\tif i < len(c) && !s.Touched() && false {", + "TestADamageThatWouldChangeNothingStopsTheFile", + ), + ( + "the writer stops putting damage in the path and writes a whole file", + "internal/engine/parallel.go", + "\tif f.Damaged() {\n\t\tdamaged, opened, err := f.Target.Damage.Open(counter)", + "\tif f.Damaged() && false {\n\t\tdamaged, opened, err := f.Target.Damage.Open(counter)", + "TestADamagedFileIsRefusedByAJudge", + ), + ( + "the manifest stops recording what was broken about a file", + "internal/engine/damage.go", + "\tif !f.Damaged() {\n\t\treturn nil\n\t}", + "\tif !f.Damaged() || true {\n\t\treturn nil\n\t}", + "TestTheManifestOfADamagedRunSaysSoAndExpectsRejection", + ), + ( + "a damaged file goes back to having no expectation declared for it", + "internal/engine/engine.go", + "\t\tif f.Damaged() {\n\t\t\treturn manifest.Expected{\n\t\t\t\tOutcome: manifest.OutcomeReject,", + "\t\tif f.Damaged() && false {\n\t\t\treturn manifest.Expected{\n\t\t\t\tOutcome: manifest.OutcomeReject,", + "TestTheManifestOfADamagedRunSaysSoAndExpectsRejection", + ), + ( + "planning stops refusing a file smaller than the damage it was given", + "internal/engine/damage.go", + "\t\tif size < floor {", + "\t\tif size < floor && false {", + "TestAFileSmallerThanItsDamageIsRefusedBeforeAnythingIsWritten", + ), + ( + # The help is rendered from the declaration now, so the way to make it + # lie is to stop rendering one entry - not to edit a string. + "a listed command stops being printed, so the help hides something the tool answers to", + "internal/cli/commands.go", + "\t\tif c.Summary == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tpad :=", + "\t\tif c.Summary == \"\" || c.Verb == \"damage\" {\n\t\t\tcontinue\n\t\t}\n\t\tpad :=", + "TestTheSiteSaysWhatTheToolSays", + ), + ( + # Take the summary OFF a real command rather than putting an empty + # one on help, which was the first version of this entry and changed + # nothing - that field was already empty. What this guard is for is a + # verb that works and nobody is told about, so that is what to build. + "a working command loses its summary, so it runs and nobody is told it exists", + "internal/cli/commands.go", + " Verb: \"formats\", Summary: \"list the formats this build supports\",", + " Verb: \"formats\", Summary: \"\",", + "TestACommandTheHelpDoesNotListOnlyPrintsTheHelp", + ), + ( + "the dispatch stops answering to a verb the help still lists", + "internal/cli/commands.go", + "\t\t\tVerb: \"formats\", Summary: \"list the formats this build supports\",", + "\t\t\tVerb: \"formatz\", Shown: \"formats\", Summary: \"list the formats this build supports\",", + "TestEveryCommandTheHelpListsIsOneTheToolAnswersTo", + ), + ( + "the README flag table loses a flag generate really takes", + "README.md", + "| `--damage ` |", + "| `--damaged ` |", + "TestTheReadmeFlagTableNamesEveryFlagGenerateTakes", + ), + ( + "the English page describes a command differently from the program", + "web/content/en/site.json", + '"formats": "list the formats this build supports",', + '"formats": "list the formats supported",', + "TestEveryLanguageDescribesEverythingTheProgramCanProduce", + ), + ( + "the one rule about damage and accept stops firing, so both surfaces go quiet at once", + "internal/damage/refusals.go", + "\tif len(c) == 0 || expected != RuledOutExpectation {", + "\tif len(c) == 0 || expected != RuledOutExpectation || true {", + "TestDamageBesideAnExpectationOfAcceptIsRefused", + ), + ( + # The engine half on its own. This is the mutation that reproduces + # O199 exactly: the recipe keeps refusing and the command line stops, + # which is the state 0.3.0 shipped in. + "the engine stops asking, so the command line writes a damaged file it calls acceptable", + "internal/engine/damage.go", + "\treturn checkDamageExpectation(t)", + "\treturn nil", + "TestTheCommandLineRefusesDamageBesideAcceptToo", + ), + ( + "the refusal comes back as a format problem, telling a script to try another format", + "internal/cli/errors.go", + "\t\treturn ExitUsage, true\n\t}\n\tif code, ok := classifyFormat(err); ok {", + "\t\treturn ExitFormat, true\n\t}\n\tif code, ok := classifyFormat(err); ok {", + "TestTheCommandLineRefusesDamageBesideAcceptToo", + ), + ( + "the word damage rules out drifts from the one the manifest knows", + "internal/damage/refusals.go", + 'const RuledOutExpectation = "accept"', + 'const RuledOutExpectation = "accepted"', + "TestTheOutcomeDamageRulesOutIsTheOneTheManifestKnows", + ), + ( + # The site half, aimed at the arithmetic rather than at the refusal + # beside it. Two earlier versions of this entry came back NOT CAUGHT + # and both were statements about the ENTRY: the first named the guard + # that reads the word lists, which never renders a page, and the second + # broke the "no summary" branch - a branch nothing can reach while + # every command has a summary, so the mutant compiled and proved + # nothing. Losing a summary is reachable only by taking one out of the + # language file, which is the entry below this one. + # + # The padding is content: the block sits in a
, so a column one
+        # space narrower is a page that differs from the published one.
+        "the command column loses a space, so the block no longer lines up the way the help does",
+        "internal/site/view.go",
+        "\t\t\tPad:     strings.Repeat(\" \", widest-len(name)+2),",
+        "\t\t\tPad:     strings.Repeat(\" \", widest-len(name)+1),",
+        "TestTheSiteSaysWhatTheToolSays",
+    ),
+    (
+        "the site stops listing a command the program prints, which is the defect this pair was built for",
+        "web/content/en/site.json",
+        '"damage": "list the ways this build can break a file on purpose",',
+        '"damaged": "list the ways this build can break a file on purpose",',
+        "TestEveryLanguageDescribesEverythingTheProgramCanProduce",
+    ),
+    (
+        "the signing script goes back to the drive that vanishes when pwsh starts it",
+        ".github/scripts/sign_release.py",
+        "        \"foreach ($where in 'CurrentUser', 'LocalMachine') { \"",
+        "        \"foreach ($where in 'CurrentUser') { \" \"# was Cert:\\CurrentUser \"",
+        "TestTheSigningScriptDoesNotDependOnWhichShellStartedIt",
+    ),
+    (
+        "the signing script hides what PowerShell said whenever the code is zero",
+        ".github/scripts/sign_release.py",
+        "    if out.stderr.strip():",
+        "    if out.stderr.strip() and False:",
+        "TestTheSigningScriptDoesNotDependOnWhichShellStartedIt",
+    ),
+    (
+        # Instead(), not Error(). The first version of this entry mutated Error
+        # and came back NOT CAUGHT - which was a statement about the MUTATION
+        # rather than about the code: a recipe reports a refusal through What,
+        # Why and Instead, and Error is only what the one-target flag path
+        # prints. The names a person needs live in Instead on that road.
+        "a refusal about an unknown damage stops naming the ones there are",
+        "internal/damage/refusals.go",
+        '\treturn "use one of: " + strings.Join(e.Known, ", ")',
+        '\treturn "remove the damage line"',
+        "TestADamageNobodyRegisteredIsRefusedWithTheNamesThereAre",
+    ),
+    (
+        "a damage setting stops being checked against its declaration",
+        "internal/damage/apply.go",
+        '\tif why == "" {',
+        '\tif why == "" || true {',
+        "TestADamageSettingOutsideItsDeclarationIsRefused",
+    ),
+    (
+        "a file nobody damaged is treated as damaged",
+        "internal/engine/engine.go",
+        "\treturn f.Target != nil && len(f.Target.Damage) > 0",
+        "\treturn f.Target != nil && len(f.Target.Damage) >= 0",
+        "TestARunWithNoDamageIsUnchanged",
+    ),
+    (
+        "the damage floor becomes a wall and refuses the size it says it takes",
+        "internal/engine/damage.go",
+        "\t\tif size < floor {",
+        "\t\tif size <= floor {",
+        "TestASizeTheDamageCanTakeIsPlanned",
+    ),
+    (
+        "the window stops drawing the fields a damage declares",
+        "internal/gui/window/generate.go",
+        "\tfields, objects := parts.DeclaredFields(text.DamageOwner(d.ID), d.Parameters, g.fields, g.tips)",
+        "\tfields, objects := parts.DeclaredFields(text.DamageOwner(d.ID), nil, g.fields, g.tips)",
+        "TestTheWindowDrawsAFieldForEveryDamageParameter",
+    ),
+    (
+        "the damage menu stops offering what the registry holds",
+        "internal/gui/window/generate.go",
+        "\tg.damage.pick = parts.NewChooser(append([]string{text.DamageNone()}, damage.Names()...),",
+        "\tg.damage.pick = parts.NewChooser(append([]string{text.DamageNone()}, []string{}...),",
+        "TestTheWindowOffersEveryDamageThereIs",
+    ),
+    (
+        "the window draws the damage menu and drops what was chosen",
+        "internal/gui/window/generate.go",
+        "\t\tDamage:     g.chosenDamage(),",
+        "\t\tDamage:     nil,",
+        "TestARunFromTheWindowIsReallyDamaged",
+    ),
+    (
+        "a picture format lets its side start at zero instead of at one pixel",
+        "internal/format/bmp/bmp.go",
+        "\t\t\timagedim.Width(imagedim.Side{Largest: maxDimension,\n\t\t\t\tDetail: \"How wide the picture is. Left out, the picture is sized to fill the bytes you asked for.\"}),",
+        "\t\t\tformat.Property{Name: imagedim.SettingWidth, Kind: format.PropertyInt,\n\t\t\t\tMin: 0, Max: maxDimension, Unit: imagedim.Unit,\n\t\t\t\tDetail: \"How wide the picture is. Left out, the picture is sized to fill the bytes you asked for.\"},",
+        "TestEveryPictureFormatDeclaresItsSidesAsTheImageDimensionPackageWould",
+    ),
+    (
+        "a deliberate homonym stops being one and the excuse stays behind",
+        "internal/format/logfile/log.go",
+        '\t\t\tName: "entry_format", Kind: format.PropertyChoice,',
+        '\t\t\tName: "entry_format", Kind: format.PropertyText,',
+        "TestEveryDeliberateHomonymStillNamesTwoMeanings",
+    ),
+    (
+        "a log stops terminating its lines, while still hitting the size to the byte",
+        "internal/format/logfile/options.go",
+        '\t\teol:        "\\n",',
+        '\t\teol:        " ",',
+        "TestEveryLogShapeWritesWholeLinesAtTheRightSize",
+    ),
+    (
+        "the counter mode counts the way Go counts, not the way WinZip does",
+        "internal/format/archive/lock.go",
+        "binary.LittleEndian.PutUint64(c.in[:8], c.n)",
+        "binary.BigEndian.PutUint64(c.in[:8], c.n)",
+        "TestARealArchiverOpensALockedArchiveAndRefusesTheWrongPassword",
+    ),
+    (
+        "a lock is charged one byte an entry more than it costs",
+        "internal/format/archive/lock.go",
+        "return int64(l.saltLen() + pwvLen + authLen)",
+        "return int64(l.saltLen() + pwvLen + authLen + 1)",
+        "TestALockedArchiveStillHitsTheSizeToTheByte",
+    ),
+    (
+        "the authentication code is remembered as nine bytes rather than ten",
+        "internal/format/archive/lock.go",
+        "\tauthLen    = 10\n",
+        "\tauthLen    = 9\n",
+        "TestTheCostOfALockIsTheMeasuredCost",
+    ),
+    (
+        "a password with the lock switched off is accepted, and the archive comes out open",
+        "internal/format/archive/lock.go",
+        "\tcase password != \"\" && !locked:\n",
+        "\tcase false && password != \"\" && !locked:\n",
+        "TestTheTwoHalvesOfALockHaveToAgree",
+    ),
+    (
+        "the salt is derived from the entry alone, so two seeds give one file",
+        "internal/format/archive/lock.go",
+        "\trng := core.NewRand(core.FileSeed(seed, index))\n",
+        "\trng := core.NewRand(uint64(index))\n",
+        "TestALockedArchiveIsTheSameFileForTheSameSeed",
+    ),
+    (
+        "a format stops saying why it cannot take a setting, so the refusal reads as a missing feature",
+        "internal/format/refusals.go",
+        "\t\tif u.Name == key {\n",
+        "\t\tif false && u.Name == key {\n",
+        "TestAFormatThatCannotLockSaysSoInItsOwnWords",
+    ),
+    (
+        "the manifest stops recording the password, so nothing can open the file it describes",
+        "internal/format/zip/zip.go",
+        "\tif m.lock.On() {\n\t\tp.Properties[archive.Encryption] = m.lock.Method\n",
+        "\tif false && m.lock.On() {\n\t\tp.Properties[archive.Encryption] = m.lock.Method\n",
+        "TestTheManifestCarriesThePasswordSoATestCanOpenTheFile",
+    ),
+    (
+        "the mode stops reaching the tar header, so every entry is 644 whatever was asked for",
+        "internal/format/targz/size.go",
+        "\t\tMode:     e.own.Mode,\n",
+        "\t\tMode:     0o644,\n",
+        "TestWhatAnArchiveSaysAboutAFileIsWhatAReaderFinds",
+    ),
+    (
+        "the default mode stops being the one this format has always written",
+        "internal/format/archive/ownership.go",
+        "\town := Ownership{Mode: 0o644, Stated: Stated{Mode: \"644\", Owner: OwnerUnset}}\n",
+        "\town := Ownership{Mode: 0o600, Stated: Stated{Mode: \"600\", Owner: OwnerUnset}}\n",
+        "TestTheDefaultOwnershipIsWhatWasAlwaysWritten",
+    ),
+    (
+        "a lock nothing implements is offered again, so the tool advertises what it fails on",
+        "internal/format/archive/archive.go",
+        "\t\tChoices: []string{AES128, AES192, AES256, NoEncryption, ZipCrypto},\n",
+        '\t\tChoices: []string{AES128, AES192, AES256, NoEncryption, ZipCrypto, "aes-512"},\n',
+        "TestEveryLockTheRegistryOffersCanActuallyBeWritten",
+    ),
+    (
+        "padding goes back into the gzip comment, where Go stops reading at 511 bytes",
+        "internal/format/targz/size.go",
+        "\t\treturn label, true, padding - 2, true\n",
+        '\t\treturn label + strings.Repeat(" ", int(padding)), false, 0, true\n',
+        "TestEveryArchiveThisToolWritesCanBeReadByTheStandardLibrary",
+    ),
+    (
+        "every locked entry declares the AES method, including the one that has to stay stored",
+        "internal/format/archive/lock.go",
+        "\tif l.keyLen() == 0 {\n\t\treturn zipStore\n\t}\n\treturn winZipAES\n",
+        "\tif l.keyLen() == 0 && false {\n\t\treturn zipStore\n\t}\n\treturn winZipAES\n",
+        "TestALockedEntryDeclaresTheShapeItsSchemeRequires",
+    ),
+    (
+        "a container stops holding contains to the ceiling entries already has",
+        "internal/format/archive/archive.go",
+        "\tif asked > maxEntries {",
+        "\tif false && asked > maxEntries {",
+        "TestBothWaysOfAskingForEntriesShareOneCeiling",
+    ),
+    (
+        "the plan memory ceiling stops refusing, so a plan grows until the machine says no",
+        "internal/engine/planmemory.go",
+        "\tif used <= p.ceiling {",
+        "\tif true || used <= p.ceiling {",
+        "TestAPlanTooBigToHoldIsRefusedWhileItIsBuilt",
+    ),
+    (
+        "the plan ceiling stops taking readings, so nothing is ever measured",
+        "internal/engine/planmemory.go",
+        "\tif p.seen < p.nextAt {",
+        "\tif true || p.seen < p.nextAt {",
+        "TestAPlanTooBigToHoldIsRefusedWhileItIsBuilt",
+    ),
+    (
+        "a run stops saying that its manifest will be too big for verify and cleanup to read",
+        "internal/cli/record.go",
+        "\tif !over {",
+        "\tif true || !over {",
+        "TestARunSaysWhenItsManifestWillBeTooBigToReadBack",
+    ),
+    (
+        "the manifest size estimate stops matching what a manifest really weighs",
+        "internal/manifest/manifest.go",
+        "\tBytesPerEntry = 786",
+        "\tBytesPerEntry = 79",
+        "TestTheManifestEstimateIsNearWhatAManifestWeighs",
+    ),
+    (
+        "the size-boundaries preset stops refusing a limit with no number above it",
+        "internal/preset/sizeboundaries.go",
+        "\t\tif limit+o.bytes < limit {",
+        "\t\tif false && limit+o.bytes < limit {",
+        "TestThePresetAnswersALimitTooLargeTheSameWayTheFlagDoes",
+    ),
+    (
+        "the plan ceiling drops to a size an ordinary run cannot stay under",
+        "internal/core/limits.go",
+        "const MaxPlanBytes = 2 << 30",
+        "const MaxPlanBytes = 2 << 10",
+        "TestAnOrdinaryRunIsNotRefusedByThePlanCeiling",
+    ),
+    (
+        "the reason given for a plan refusal names a ceiling other than the one in force",
+        "internal/core/limits.go",
+        "\t\tHumanBytes(MaxPlanBytes))",
+        "\t\tHumanBytes(MaxPlanBytes/2))",
+        "TestThePlanCeilingIsTheOneCoreStates",
+    ),
+    (
+        "a container starts refusing exactly the number of entries it says it holds",
+        "internal/format/archive/archive.go",
+        "\tif asked > maxEntries {",
+        "\tif asked >= maxEntries {",
+        "TestTheEntryCeilingStillAcceptsTheNumberBelowIt",
+    ),
+    (
+        "every run starts warning that its manifest is too big to read back",
+        "internal/cli/record.go",
+        "\tif !over {",
+        "\tif !over && false {",
+        "TestAnOrdinaryRunSaysNothingAboutTheManifestCeiling",
+    ),
+    (
+        "free space too large to count wraps into a negative number again",
+        "internal/core/diskspace.go",
+        "\tif blocks > uint64(math.MaxInt64)/blockSize {",
+        "\tif false && blocks > uint64(math.MaxInt64)/blockSize {",
+        "TestFreeSpaceTooLargeToCountIsNotANegativeNumber",
+    ),
+    (
+        "a block size the system never reported divides by nought instead of being answered",
+        "internal/core/diskspace.go",
+        "\tif blockSize == 0 {",
+        "\tif false && blockSize == 0 {",
+        "TestFreeSpaceTooLargeToCountIsNotANegativeNumber",
+    ),
+    (
+        "a filler with nothing to add goes back to spinning without end",
+        "internal/core/records.go",
+        "\tif !wordsHaveBytes(words) {",
+        "\tif false && !wordsHaveBytes(words) {",
+        "TestAFillerWithNothingToAddRefusesRatherThanSpinning",
+    ),
+    (
+        "a filler refuses every vocabulary, including the ones that pad perfectly well",
+        "internal/core/records.go",
+        "\tif !wordsHaveBytes(words) {",
+        "\tif !wordsHaveBytes(words) || true {",
+        "TestAFillerWithOneRealWordAmongEmptyOnesStillPadsToTheByte",
+    ),
+    (
+        "the ceiling a caller does not name stops being the real one, so ordinary short runs are refused",
+        "internal/engine/planmemory.go",
+        "\t\tceiling = core.MaxPlanBytes",
+        "\t\tceiling = 1",
+        "TestAShortOrdinaryRunIsNotRefusedByThePlanCeiling",
+    ),
+    (
+        "the plan ceiling goes back to ignoring the first sixty three files of a run",
+        "internal/engine/planmemory.go",
+        "nextAt:         1,",
+        "nextAt:         64,",
+        "TestARunTooShortToHaveBeenWeighedIsWeighed",
+    ),
+    (
+        "a crash in a generator stops being one file's failure and takes the run",
+        "internal/engine/crash.go",
+        '\t\terr = &GeneratorCrashError{Format: f.Desc.ID, While: "writing", Name: f.Name, Value: v}',
+        "\t\t_ = v",
+        "TestAGeneratorThatCrashesWhileWritingCostsOneFile",
+    ),
+    (
+        "the engine calls a generator directly again, so a crash while planning ends the process",
+        "internal/engine/plantarget.go",
+        "planWithoutCrashing(ctx, desc, format.Request{",
+        "desc.Generator.Plan(format.Request{",
+        "TestEveryCallIntoAGeneratorGoesThroughTheCrashWrapper",
+    ),
+    (
+        "zip stops refusing an archive whose size its own arithmetic cannot predict",
+        "internal/format/zip/zip.go",
+        "\tif total <= zip32Ceiling {",
+        "\tif true || total <= zip32Ceiling {",
+        "TestAContainerRefusesASizeItsArithmeticCannotSee",
+    ),
+    (
+        "an Office package stops refusing a size past what a zip32 archive describes",
+        "internal/format/opc/opc.go",
+        "\tif want > Zip32Ceiling {",
+        "\tif false && want > Zip32Ceiling {",
+        "TestAContainerRefusesASizeItsArithmeticCannotSee",
+    ),
+    (
+        "preflight stops noticing that it was asked to stop",
+        "internal/engine/engine.go",
+        "\tif err := preflight(ctx, files, opt); err != nil {",
+        "\tif err := preflight(context.Background(), files, opt); err != nil {",
+        "TestWorkingOutTheCostCanBeStopped",
+    ),
+    (
+        "planning stops noticing that it was asked to stop",
+        "internal/engine/plantarget.go",
+        "\t\tif err := ctx.Err(); err != nil {",
+        "\t\tif false {",
+        "TestWorkingOutTheCostCanBeStopped",
+    ),
+    (
+        "a cleanup report goes back to counting only blocked files as kept",
+        "internal/cli/cleanup.go",
+        "\treport.Removed, report.Kept = removed, len(outcomes)-removed",
+        "\treport.Removed, report.Kept = removed, blocked",
+        "TestACleanupReportAddsUp",
+    ),
+    (
+        "a boundary that could not be resolved answers that everything stays inside it",
+        "internal/core/filename.go",
+        "\tif !b.known {\n\t\treturn true\n\t}",
+        "\tif !b.known {\n\t\treturn false\n\t}",
+        "TestABoundaryThatCannotTellRefuses",
+    ),
+    (
+        "a boundary starts calling its own files escapes, so every ordinary run is refused",
+        "internal/core/filename.go",
+        "!crossesUnresolvedLink(b.resolved, rel) {\n\t\treturn false\n\t}",
+        "!crossesUnresolvedLink(b.resolved, rel) {\n\t\treturn true\n\t}",
+        "TestABoundaryThatKnowsStillLetsItsOwnFilesThrough",
+    ),
+    (
+        "a run whose manifest could not be saved stops saying that it left files nothing can remove",
+        "internal/cli/record.go",
+        "\t\tif n := len(res.Manifest.Files); n > 0 {",
+        "\t\tif n := len(res.Manifest.Files); false && n > 0 {",
+        "TestARunThatCannotSaveItsManifestSaysWhatItLeftBehind",
+    ),
+    (
+        "a package that exists for the tests is linked into the shipped binaries",
+        "internal/cli/cli.go",
+        "\t_ \"github.com/donislawdev/TestingFilesGenerator/internal/format/all\"",
+        "\t_ \"github.com/donislawdev/TestingFilesGenerator/internal/format/all\"\n\t_ \"github.com/donislawdev/TestingFilesGenerator/internal/site\"",
+        "TestThePackagesOnlyTestsUseStayOutOfBothBinaries",
+    ),
+    (
+        "where the manifest lands stops falling back to the default name, so a caller that leaves it blank aims at the output directory itself",
+        "internal/engine/preflight.go",
+        "func manifestNameOf(opt Options) string {\n\tif opt.ManifestName == \"\" {",
+        "func manifestNameOf(opt Options) string {\n\tif false && opt.ManifestName == \"\" {",
+        "TestWhereTheManifestLandsIsOneAnswer",
+    ),
+    (
+        "a preview marks the screen busy without marking it stoppable, so work that can be cancelled offers no way to cancel it",
+        "internal/gui/window/run.go",
+        "\tr.busy.set(true, busyFace{stoppable: true})\n\tr.say(text.WorkingOutTheCost())",
+        "\tr.busy.set(true, busyFace{})\n\tr.say(text.WorkingOutTheCost())",
+        "TestAPreviewOffersTheWayOut",
+    ),
+    (
+        "a format declares a minimum nobody could ever meet, quietly, so it refuses every size ever asked of it",
+        "internal/format/png/png.go",
+        "\treturn body + iendSize\n}",
+        "\treturn body + 1<<62\n}",
+        "TestNoFormatDeclaresAMinimumNobodyCouldMeet",
+    ),
+    (
+        "the manifest stops saying which Go built the run, so a hash that moved after a rebuild cannot be told from one that moved because the recipe changed",
+        "internal/manifest/manifest.go",
+        "\t\t\tGo:         runtime.Version(),",
+        "\t\t\tGo:         runtime.Version()[:0],",
+        "TestTheManifestNamesTheToolchainThatBuiltTheRun",
+    ),
+    (
+        "the manifest entry stops naming the target the file came from, so counting files per target means parsing names again",
+        "internal/engine/engine.go",
+        "\t\tTargetID:      f.Target.ID,",
+        "\t\tTargetID:      f.Target.Group,",
+        "TestTheManifestSaysWhichTargetEachFileCameFrom",
+    ),
+    (
+        "the summary stops breaking a run down by target, so the cheap answer and the entries disagree",
+        "internal/manifest/manifest.go",
+        "\tif f.TargetID != \"\" {",
+        "\tif false && f.TargetID != \"\" {",
+        "TestTheManifestSaysWhichTargetEachFileCameFrom",
+    ),
+    (
+        "a generator allocates its write buffer once per chunk instead of once per file, which leaves the bytes identical",
+        "internal/format/txt/txt.go",
+        "\tbuf := make([]byte, 0, chunkSize+16)\n\tcol := 0\n\tfor remaining > 0 {",
+        "\tcol := 0\n\tfor remaining > 0 {\n\t\tbuf := make([]byte, 0, chunkSize+16)",
+        "TestNoGeneratorHoldsTheWholeFileInMemory",
+    ),
+    (
+        "the recipe reader takes the bytes of a node instead of the node, so goccy renders every value through its document formatter",
+        "internal/recipe/scalar.go",
+        "func (s *scalar) UnmarshalYAML(n ast.Node) error {\n\tb := []byte(n.String())",
+        "func (s *scalar) UnmarshalYAML(b []byte) error {\n\tvar _ ast.Node",
+        "TestReadingARecipeDoesNotCostTheSquareOfItsSize",
+    ),
+    (
+        "verify stops telling one of our own files under another spelling apart from somebody else's file",
+        "internal/audit/audit.go",
+        "return Respelled, claimedAs",
+        "return Extra, claimedAs",
+        "TestOneFileUnderTwoSpellingsIsNamedAndNotDeleted",
+    ),
+    (
+        "cleanup stops checking how the directory really spells a name, so a filesystem that ignores case lets it delete a name the manifest does not list",
+        "internal/audit/cleanup.go",
+        "\t\tif actual, known := stored.differing(full); known {",
+        "\t\tif actual, known := stored.differing(full); known && false {",
+        "TestOneFileUnderTwoSpellingsIsNamedAndNotDeleted",
+    ),
+    (
+        "the sitemap declares a second namespace again, which is what makes a strict reader go looking for a schema it has not got",
+        "internal/site/render.go",
+        'schemas/sitemap/0.9">',
+        'schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
+        "TestTheSitemapNeedsNoSchemaButItsOwn",
+    ),
+    (
+        "the sitemap names pages by path rather than by address, so it points a crawler at nothing",
+        "internal/site/render.go",
+        "s.Origin(), pageURL(lang, page))",
+        '"", pageURL(lang, page))',
+        "TestTheSitemapNeedsNoSchemaButItsOwn",
+    ),
+    (
+        "a menu with a declared default opens on nothing, so a fresh screen shows a blank where the format has an answer",
+        "internal/gui/parts/property.go",
+        "sel.SetSelected(p.Default)",
+        'sel.SetSelected("")',
+        "TestAMenuOpensOnItsDeclaredDefault",
+    ),
+    (
+        "a menu of every format draws no file kind pictures, so one list of formats looks unlike the others",
+        "internal/gui/parts/ring.go",
+        "if IsEveryFormat(options) {",
+        "if IsEveryFormat(options) && false {",
+        "TestEveryMenuOfferingEveryFormatDrawsTheKindPictures",
+    ),
+    (
+        # Aimed at the row rather than at the box since 2026-09-01. The box
+        # half - turning off the widening in ring.go - stopped reddening this
+        # guard, and measuring says the branch is unreachable for every menu
+        # this window has today: the same tightest slot is reported with it on
+        # and off. That is a question about a defence nobody can turn red,
+        # recorded as an observation rather than answered here.
+        #
+        # This one breaks what the guard names instead: the room a row gives
+        # its words. Four values come out cut off and the guard names each.
+        "a row gives its words less room than it promised, so values are cut off in the open list",
+        "internal/gui/parts/listrow.go",
+        "r.label.Resize(fyne.NewSize(size.Width-left-right, text.Height))",
+        "r.label.Resize(fyne.NewSize(size.Width-left-right-rowGutter*3, text.Height))",
+        "TestEveryValueInAMenuFitsInTheListItOpens",
+    ),
+    (
+        # O220 put back: the tick in front of the words and its column kept
+        # whether or not anything is ticked, so the words of every list stand
+        # 36 px right of the word in the box. The guard reads the positions
+        # off the canvas, box against row.
+        "the tick goes back in front of the words and pushes them off the box's word",
+        "internal/gui/parts/listrow.go",
+        "\tr.tick.Move(fyne.NewPos(size.Width-rowGutter-icon, (size.Height-icon)/2))\n"
+        "\tright += icon + rowGap\n",
+        "\tr.tick.Move(fyne.NewPos(rowGutter, (size.Height-icon)/2))\n"
+        "\tleft += icon + rowGap\n",
+        "TestTheWordsInAnOpenListStartWhereTheWordInTheBoxDoes",
+    ),
+    (
+        # O223 put back: the switch goes on the form and into no list, so
+        # Freeze never reaches it and a press on another way of stating a
+        # size rebuilds the boxes under a form drawn as frozen. Measured on
+        # a render of the batch screen mid run before the fix.
+        "the unlabelled switch is put on the form and registered nowhere",
+        "internal/gui/parts/fields.go",
+        "\t\ts.bare = append(s.bare, bareControl{control: d, after: len(s.list)})",
+        "\t\t_ = d",
+        "TestTheSizeWaySwitchFreezesWithTheRestOfTheForm",
+    ),
+    (
+        # The other half of O223: a frozen switch drops the chosen fill, so
+        # a form frozen for a run no longer says which way it is running with.
+        "a frozen switch forgets which way is chosen",
+        "internal/gui/parts/segments.go",
+        "\t\tif chosen {\n\t\t\tfill = PaletteColour(theme.ColorNameSelection, dark)\n\t\t}\n\tcase chosen:",
+        "\tcase chosen:",
+        "TestAFrozenSwitchStillShowsWhichWayIsChosen",
+    ),
+    (
+        # O219 put back, first geometry: the fill laid at the switch's own
+        # edge, under the border's stroke, so its corner stands out past the
+        # border's arc.
+        "the chosen fill reaches under the border again",
+        "internal/gui/parts/segments.go",
+        "\t\tr.fills[i].Move(fyne.NewPos(x+edgeWidth, edgeWidth))",
+        "\t\tr.fills[i].Move(fyne.NewPos(x, 0))",
+        "TestTheChosenSegmentStaysInsideTheBorderAndNoRuleTouchesIt",
+    ),
+    (
+        # O219 put back, second geometry: a hairline against the chosen
+        # segment's edge.
+        "a rule is drawn against the chosen segment",
+        "internal/gui/parts/segments.go",
+        "\t\tif chosen != i && chosen != i+1 {",
+        "\t\tif true {",
+        "TestTheChosenSegmentStaysInsideTheBorderAndNoRuleTouchesIt",
+    ),
+    (
+        "changing the format hides the archive contents somebody typed, so a refusal names a box nobody can see",
+        "internal/gui/window/recipe.go",
+        "if len(b.contents) == 0 {",
+        "if len(b.contents) == 0 || !holds {",
+        "TestContentsAlreadyTypedStayOnScreenWhenTheFormatChanges",
+    ),
+    (
+        "the format menu stops drawing the kind of each format",
+        "internal/gui/parts/filekind.go",
+        "func KindOfFile(id string) fyne.Resource {\n\tswitch fileKinds[id] {",
+        "func KindOfFile(id string) fyne.Resource {\n\tswitch kindUnknown {",
+        "TestTheFormatMenuDrawsThePictureOfEachKind",
+    ),
+    (
+        "a menu reorders the choices the declaration gave it",
+        "internal/gui/parts/property.go",
+        "NewChooser(p.Choices, nil)",
+        "NewChooser(append([]string{p.Default}, p.Choices...), nil)",
+        "TestAMenuOffersItsChoicesInTheDeclaredOrder",
+    ),
+    (
+        "a batch of any format offers to put files inside it, which leads nowhere but a refusal",
+        "internal/gui/window/recipe.go",
+        "holds = d.Container",
+        "holds = d.Container || true",
+        "TestOnlyAFormatThatHoldsFilesOffersToPutFilesInIt",
+    ),
+    (
+        "the depth metric stops counting a closure as a level, so the ceiling "
+        "standing on it measures something shallower than what a person reads "
+        "indented - and nothing else in the tree would notice, because a "
+        "metric that reports LESS depth never trips the ceiling",
+        "internal/guard/codeshape_test.go",
+        "\t\tcase *ast.FuncLit:\n\t\t\twalk(node.Body, depth+1)",
+        "\t\tcase *ast.FuncLit:\n\t\t\twalk(node.Body, depth)",
+        "TestTheNestingMetricMeasuresWhatAPersonSeesIndented",
+    ),
+    (
+        "a linter is dropped from the configuration to turn a red build green, "
+        "and the gate that vanished cannot fail to announce itself",
+        ".golangci.yml",
+        "    - errorlint\n",
+        "",
+        "TestTheLinterSetOnlyEverGrows",
+    ),
+    (
+        "the linter set goes back to inheriting whatever the tool ships with, "
+        "so the written down list stops describing the whole set",
+        ".golangci.yml",
+        "  default: none\n",
+        "  default: standard\n",
+        "TestTheLinterSetOnlyEverGrows",
+    ),
+    (
+        "staticcheck is switched on under golangci-lint as well, which quietly "
+        "swaps a gate that honours //lint:ignore for one that does not",
+        ".golangci.yml",
+        "    - ineffassign\n",
+        "    - ineffassign\n    - staticcheck\n",
+        "TestTheLinterSetOnlyEverGrows",
+    ),
+    (
+        "CI stops verifying the linter configuration, so a key the schema "
+        "refuses is ignored in silence",
+        ".github/workflows/ci.yml",
+        "golangci-lint@v2.14.0 config verify",
+        "golangci-lint@v2.14.0 version",
+        "TestTheWorkflowRunsTheLinterAndVerifiesItsConfiguration",
+    ),
+    (
+        "CI reads the linter configuration and never runs it against the tree",
+        ".github/workflows/ci.yml",
+        'golangci-lint@v2.14.0 run --build-tags "$(cat .github/build-tags)" ./...',
+        "golangci-lint@v2.14.0 help",
+        "TestTheWorkflowRunsTheLinterAndVerifiesItsConfiguration",
+    ),
+    (
+        "the function ceiling is left one line above the longest function, the "
+        "same silent headroom one axis over",
+        "internal/guard/codeshape_test.go",
+        "\tlongestFunction = 75\n",
+        "\tlongestFunction = 76\n",
+        "TestTheCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the crowd cap is left one above the count, so the next function to "
+        "reach the band arrives without a word",
+        "internal/guard/crowding_test.go",
+        "\tcrowdedFunctions = 9\n",
+        "\tcrowdedFunctions = 10\n",
+        "TestTheCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the crowding band stops being a number of lines and goes back to a "
+        "share of the ceiling, so lowering a ceiling reshapes the band under it",
+        "internal/guard/crowding_test.go",
+        "func crowding(n, band int) bool {\n\treturn n >= band\n}",
+        "func crowding(n, band int) bool {\n\treturn n*100 >= band*75\n}",
+        "TestTheCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the ceiling on guard files is left one line above the biggest one, so "
+        "the half of the tree that grows fastest gets headroom nobody granted",
+        "internal/guard/testshape_test.go",
+        "\tlongestTestFile     = 726\n",
+        "\tlongestTestFile     = 727\n",
+        "TestTheGuardCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the depth ceiling for guards is left above the deepest one",
+        "internal/guard/testshape_test.go",
+        "\tdeepestTestNesting  = 7\n",
+        "\tdeepestTestNesting  = 8\n",
+        "TestTheGuardCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the crowd cap for guards is left one above the count, so the next long "
+        "guard joins the band without a word",
+        "internal/guard/testshape_test.go",
+        "\tcrowdedTestFunctions = 1\n",
+        "\tcrowdedTestFunctions = 2\n",
+        "TestTheGuardCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the scan over guard files reads nothing, which satisfies every ceiling "
+        "ever set and looks exactly like a guard that works",
+        "internal/guard/testshape_test.go",
+        "\t\tfor _, path := range p.tests {",
+        "\t\tfor _, path := range p.tests[:0] {",
+        "TestNoGuardHasGrownPastWhatAPersonCanFollow",
+    ),
+    (
+        "the method ceiling is left one above the widest type, which grants "
+        "headroom nobody decided to grant on the axis a file split cannot answer",
+        "internal/guard/typeshape_test.go",
+        "\tmostMethods = 25\n",
+        "\tmostMethods = 26\n",
+        "TestTheTypeCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the field ceiling is left one above the widest type, so a type may take "
+        "on one more piece of state than anybody agreed to",
+        "internal/guard/typeshape_test.go",
+        "\tmostFields  = 21\n",
+        "\tmostFields  = 22\n",
+        "TestTheTypeCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the cap on types crowding the method ceiling is left one above the "
+        "count, so the next type to reach the band arrives without a word",
+        "internal/guard/typeshape_test.go",
+        "\tcrowdedMethodTypes = 3\n",
+        "\tcrowdedMethodTypes = 4\n",
+        "TestTheTypeCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the cap on types crowding the field ceiling is left one above the count",
+        "internal/guard/typeshape_test.go",
+        "\tcrowdedFieldTypes  = 4\n",
+        "\tcrowdedFieldTypes  = 5\n",
+        "TestTheTypeCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the type scan reads nothing, which satisfies both ceilings perfectly "
+        "and looks exactly like a scan that works",
+        "internal/guard/typeshape_test.go",
+        "\t\tfor _, path := range p.files {",
+        "\t\tfor _, path := range p.files[:0] {",
+        "TestNoTypeHasGrownPastWhatAPersonCanHoldInTheirHead",
+    ),
+    (
+        "the method band drops far enough that ordinary types are inside it, "
+        "which is what the cap on the band exists to notice",
+        "internal/guard/typeshape_test.go",
+        "\tcrowdingMethods = 21\n",
+        "\tcrowdingMethods = 11\n",
+        "TestNoSecondTypeIsCreepingUpOnTheTypeCeilings",
+    ),
+    (
+        "the field band drops far enough that ordinary types are inside it",
+        "internal/guard/typeshape_test.go",
+        "\tcrowdingFields  = 17\n",
+        "\tcrowdingFields  = 9\n",
+        "TestNoSecondTypeIsCreepingUpOnTheTypeCeilings",
+    ),
+    (
+        "a debug line is left in a library below the surface, printed with the "
+        "builtin that needs no import - it fails no run, trips no exit code and "
+        "turns a --json report into text no parser accepts",
+        "internal/core/size.go",
+        "func ParseSize(s string) (int64, error) {\n\traw := strings.TrimSpace(s)",
+        "func ParseSize(s string) (int64, error) {\n\tprintln(\"parsing\", s)\n\traw := strings.TrimSpace(s)",
+        "TestNothingBelowASurfaceReachesTheConsoleDirectly",
+    ),
+    (
+        "the engine says something on stdout on the SUCCESS path, where the "
+        "guard that a failed run prints nothing cannot see it",
+        "internal/engine/engine.go",
+        "func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error) {\n\tm := manifest.New(",
+        "func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error) {\n\tfmt.Println(\"running\")\n\tm := manifest.New(",
+        "TestNothingBelowASurfaceReachesTheConsoleDirectly",
+    ),
+    (
+        "the console scan reads no packages at all, which finds no offender and "
+        "looks exactly like a scan that works",
+        "internal/guard/consolereach_test.go",
+        "\tfor _, p := range packages(t) {",
+        "\tfor _, p := range packages(t)[:0] {",
+        "TestNothingBelowASurfaceReachesTheConsoleDirectly",
+    ),
+    (
+        "the support address comes off the About screen, which turns the "
+        "considered silence in OpenLink back into a dead end - the button does "
+        "nothing and there is nowhere left to read the address",
+        "internal/gui/window/about.go",
+        # Repointed 2026-09-23, when Donate moved into this card, and on
+        # 2026-10-14 into linkCard, which draws this card and Source code.
+        "\t\tparts.Prose(address))",
+        "\t\t)",
+        "TestTheSupportAddressIsReadableOnAScreen",
+    ),
+    (
+        "the support address is folded into the sentence beside it, so it is on "
+        "the screen and no longer a line somebody can select in one gesture",
+        "internal/gui/window/about.go",
+        # Repointed 2026-09-23, when Donate moved into this card, and on
+        # 2026-10-14 into linkCard, which draws this card and Source code.
+        "\t\tparts.Prose(address))",
+        "\t\tparts.Prose(detail+\" \"+address))",
+        "TestTheSupportAddressStandsOnItsOwnLine",
+    ),
+    (
+        "the branching ceiling is left above the most branching function, so a "
+        "function can climb toward it without anything saying so",
+        "internal/guard/branching_test.go",
+        "\tworstComplexity = 22 ",
+        "\tworstComplexity = 23 ",
+        "TestTheBranchingCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the argument ceiling is left above the widest signature",
+        "internal/guard/branching_test.go",
+        "\tmostArguments = 7 ",
+        "\tmostArguments = 8 ",
+        "TestTheBranchingCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the depth crowd count is left above the measurement, which is the one "
+        "that matters most here - a function sits one level under a ceiling "
+        "that was already exact, and no guard could see it",
+        "internal/guard/branching_test.go",
+        "\tcrowdedDepthFunctions = 49\n",
+        "\tcrowdedDepthFunctions = 50\n",
+        "TestTheBranchingCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the branching count stops counting short circuit operators, so a "
+        "function full of && reads as simple",
+        "internal/guard/ratchet_test.go",
+        "\t\t\tif v.Op.String() == \"&&\" || v.Op.String() == \"||\" {",
+        "\t\t\tif v.Op.String() == \"&&\" && v.Op.String() == \"||\" {",
+        "TestTheBranchingCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "counting arguments stops counting the names sharing one type, so "
+        "func(a, b, c int) reads as one argument",
+        "internal/guard/ratchet_test.go",
+        "\t\tn += len(f.Names)",
+        "\t\tn++",
+        "TestTheBranchingCeilingsAreTodaysMeasurementAndNotALooserNumber",
+    ),
+    (
+        "the cap on branching functions is pushed BELOW what the tree measures, "
+        "which is how this guard reports a tree that has crossed it",
+        "internal/guard/branching_test.go",
+        "\tcrowdedComplexity = 4\n",
+        "\tcrowdedComplexity = 3\n",
+        "TestNothingBranchesOrTakesMoreThanWhatAPersonCanFollow",
+    ),
+    (
+        "the cap on wide signatures is pushed below the measurement, one axis over",
+        "internal/guard/branching_test.go",
+        "\tcrowdedArguments = 3\n",
+        "\tcrowdedArguments = 2\n",
+        "TestNothingBranchesOrTakesMoreThanWhatAPersonCanFollow",
+    ),
+    (
+        "an action goes back to a tag somebody else can repoint, which is the "
+        "supply chain question these workflows answer nowhere else",
+        ".github/workflows/pages.yml",
+        "actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346",
+        "actions/deploy-pages@v5",
+        "TestEveryActionIsPinnedToACommitRatherThanATag",
+    ),
+    (
+        "the rule stops asking whether a reference is hexadecimal, so forty "
+        "characters of anything would read as a commit",
+        "internal/guard/actionpinning_test.go",
+        "if !digit && !lower {",
+        "if !digit && !lower && false {",
+        "TestEveryActionIsPinnedToACommitRatherThanATag",
+    ),
+    (
+        "a licence GitHub could not determine passes, which is exactly the half "
+        "the action already declines to fail on",
+        ".github/scripts/dependency_gate.py",
+        'return "unknown", licence',
+        'return "ok", licence',
+        "TestTheDependencyGateRefusesALicenceThisProjectCannotDistribute",
+    ),
+    (
+        "the vulnerability half is loosened so only a critical one stops a pull request",
+        ".github/workflows/dependency-review.yml",
+        "fail-on-severity: moderate",
+        "fail-on-severity: critical",
+        "TestCIAsksTheDependencyGateOnEveryPullRequest",
+    ),
+    (
+        "a gate that never read its answer reports that it found no problems",
+        ".github/scripts/dependency_gate.py",
+        '(args.review, exc), file=sys.stderr)\n        return 2',
+        '(args.review, exc), file=sys.stderr)\n        return 0',
+        "TestTheDependencyGateFailsClosedWhenItCannotReadTheAnswer",
+    ),
+    (
+        "the gate blocks only what the scanner's own severity flag can express, "
+        "so a registry rule marked HIGH or CRITICAL passes unseen",
+        ".github/scripts/semgrep_gate.py",
+        'BLOCKING = ("ERROR", "HIGH", "CRITICAL")',
+        'BLOCKING = ("ERROR",)',
+        "TestTheSemgrepGateBlocksTheSeveritiesTheFlagCannotExpress",
+    ),
+    (
+        "a rule that failed to run counts as a rule that found nothing",
+        ".github/scripts/semgrep_gate.py",
+        'str(e.get("level", "")).lower() == "error"',
+        'str(e.get("level", "")).lower() != "error"',
+        "TestASemgrepScanErrorIsNotACleanScan",
+    ),
+    (
+        "a scan that read no file at all reports a clean tree",
+        ".github/scripts/semgrep_gate.py",
+        "if not scanned:",
+        "if not scanned and False:",
+        "TestTheSemgrepGateFailsClosedWhenTheScanDidNotHappen",
+    ),
+    (
+        "the scanner starts sending telemetry about this repository's own source",
+        ".github/workflows/ci.yml",
+        "--metrics=off",
+        "--metrics=on",
+        "TestCIRunsSemgrepAndDecidesFromTheReportRatherThanTheExitCode",
+    ),
+    (
+        "the publish job stops asking whose repository the run came from, so a fork's "
+        "pull request can reach the job that deploys the website",
+        ".github/workflows/pages.yml",
+        "github.event.workflow_run.head_repository.full_name == github.repository",
+        "github.event.workflow_run.head_branch == 'main'",
+        "TestTheSiteIsPublishedOnlyForAPushToThisRepository",
+    ),
+    (
+        "the push trigger loses its branch filter, so every branch push runs the whole "
+        "suite a second time beside the pull request that already runs it",
+        ".github/workflows/ci.yml",
+        "  push:\n    branches: [main]\n",
+        "  push:\n",
+        "TestTheSuiteRunsOncePerChange",
+    ),
+    (
+        "the pull request trigger is dropped, which stops the doubling by leaving a "
+        "fork's pull request unchecked and the required contexts never reporting",
+        ".github/workflows/ci.yml",
+        "    branches: [main]\n  pull_request:\n",
+        "    branches: [main]\n",
+        "TestTheSuiteRunsOncePerChange",
+    ),
+    (
+        "the race detector goes back to asking a push-only field, so every pull request "
+        "counts as touched and it runs on all of them again",
+        ".github/workflows/ci.yml",
+        'before="${{ github.event.pull_request.base.sha || github.event.before }}"',
+        'before="${{ github.event.before }}"',
+        "TestTheRaceDetectorAsksAboutWhatThePullRequestChanges",
+    ),
+    (
+        "the private reporting link still names the project these files were copied "
+        "from, so a vulnerability report goes to somebody else's repository",
+        ".github/ISSUE_TEMPLATE/config.yml",
+        "url: https://github.com/donislawdev/TestingFilesGenerator/security/advisories/new",
+        "url: https://github.com/donislawdev/BeanNetworkTester/security/advisories/new",
+        "TestNothingThatDirectsAPersonPointsAtAnotherProject",
+    ),
+    (
+        "two fields of one issue form share an id, which makes GitHub refuse the whole form",
+        ".github/ISSUE_TEMPLATE/question.yml",
+        "    id: question\n",
+        "    id: extra\n",
+        "TestTheIssueFormsAreShapedTheWayGitHubNeeds",
+    ),
+    (
+        "the dependency review takes back a write scope on pull requests, for a comment "
+        "that a red check already makes",
+        ".github/workflows/dependency-review.yml",
+        "  contents: read\n",
+        "  contents: read\n  pull-requests: write\n",
+        "TestTheDependencyReviewAsksForNoWriteScope",
+    ),
+    (
+        # The defect O150 records, put back: a file ships inside somebody
+        # else's module, under a licence of its own, and no list here knows
+        # about it. Seven fonts and ninety-seven images did exactly that for as
+        # long as the window existed, and every check passed the whole time,
+        # because every check asked about modules.
+        "a font ships with nothing in the registry accounting for it",
+        "internal/legal/assets.go",
+        '"font/EmojiOneColor.otf"',
+        '"font/EmojiOneColour.otf"',
+        "TestEveryFileEmbeddedFromSomebodyElseIsAccountedFor",
+    ),
+    (
+        # The registry is code and the notices are what travels with a
+        # download. A name in one and not the other means the person holding
+        # the binary cannot read what they were given.
+        "the notices stop naming something the registry says we ship",
+        "THIRD-PARTY-NOTICES.md",
+        "| Fyne shaders, 14 programs that draw the screen |",
+        "| Shaders, 14 programs that draw the screen |",
+        "TestEveryEmbeddedAssetIsNamedInTheNotices",
+    ),
+    (
+        # A module arrives and nobody reads its licence. The notices guard
+        # catches the same arrival against the document. This one catches it
+        # against the list an SBOM will be generated from, which is the copy a
+        # machine reads.
+        "a linked module has no entry in the licence registry",
+        "internal/legal/modules.go",
+        '{Path: "github.com/yuin/goldmark", SPDX: "MIT"',
+        '{Path: "github.com/yuin/goldmark-gone", SPDX: "MIT"',
+        "TestEveryModuleTheBinariesLinkHasAReviewedLicence",
+    ),
+    (
+        # An empty field reads like an answer. One module here genuinely has no
+        # copyright line and says so in a note, so a blank has to be the
+        # deliberate kind rather than the forgotten kind.
+        "an entry loses its copyright line and says nothing about why",
+        "internal/legal/modules.go",
+        'SPDX: "MIT", Copyright: "(c) 2019 Yusuke Inuzuka"',
+        'SPDX: "MIT", Copyright: ""',
+        "TestNoEntryInTheRegistryIsSilentAboutItsLicence",
+    ),
+    (
+        # Two written copies of one fact are safe only while something compares
+        # them. Without this the code could publish one licence in an SBOM
+        # while the notices in the same archive stated another.
+        "the registry and the notices disagree about one licence",
+        "internal/legal/modules.go",
+        '{Path: "github.com/mattn/go-runewidth", SPDX: "MIT"',
+        '{Path: "github.com/mattn/go-runewidth", SPDX: "MIT-0"',
+        "TestTheRegistryAndTheNoticesAgreeOnEveryLicence",
+    ),
+    (
+        # The real defect found on 2026-08-28, put back. The notices said
+        # golang.org/x/text was 0.40.0 while every binary linked 0.41.0, and
+        # the guard was green because it compared table rows only - and the two
+        # modules of the command line binary are named by a section instead. A
+        # section carries the whole licence text, so it was the stronger notice
+        # with the weaker check.
+        "a module's own section states a version nobody builds",
+        "THIRD-PARTY-NOTICES.md",
+        # The number here follows the build: 0.40 became 0.41 on 2026-08-28
+        # and 0.42 on 2026-09-22 (#112), and each time this pattern went stale
+        # with it - a SKIP that reads like a proof, which staleness.py names.
+        "Version 0.42.0. Supplies Unicode normalisation",
+        "Version 0.41.0. Supplies Unicode normalisation",
+        "TestTheNoticesFileNamesTheVersionsThatAreActuallyBuilt",
+    ),
+    (
+        # The window stops showing the list while the command still prints it.
+        # Two surfaces, two different answers about what the program is made
+        # of - and which one a person reads depends on which file they
+        # downloaded.
+        "the about screen stops naming what the window carries",
+        "internal/gui/window/about.go",
+        "items := legal.Reviewed()",
+        "items := legal.Reviewed()[:0]",
+        "TestTheWindowShowsTheSameLicencesAsTheCommandDoes",
+    ),
+    (
+        # An asset entry names a module that does not bring it. Nothing breaks
+        # loudly: the entry simply stops matching the build, so the font drops
+        # off the list the command prints and nobody is told.
+        "an asset entry names the wrong module",
+        "internal/legal/assets.go",
+        "\t\tPackage:   \"fyne.io/fyne/v2/lang\",\n\t\tModule:    \"fyne.io/fyne/v2\",",
+        "\t\tPackage:   \"fyne.io/fyne/v2/lang\",\n\t\tModule:    \"fyne.io/fyne/v3\",",
+        "TestEveryAssetEntryNamesTheModuleItComesFrom",
+    ),
+    (
+        # The command goes back to pointing at a file instead of answering.
+        # That was the state until 2026-08-28, and it is useless to the person
+        # the notice is for: somebody holding one downloaded binary has no
+        # THIRD-PARTY-NOTICES.md anywhere near it.
+        #
+        # Sliced to nothing rather than replaced by nil, because nil leaves the
+        # import unused and a mutation that stops the tree compiling proves
+        # nothing. It went BROKEN exactly that way on the first attempt.
+        "the licence command stops listing what the binary carries",
+        "internal/cli/commands.go",
+        "printCarried(out, legal.CarriedHere())",
+        "printCarried(out, legal.CarriedHere()[:0])",
+        "TestTheLicenceCommandListsWhatTheBinaryLinks",
+    ),
+    (
+        # The document goes back to describing modules only - which is what
+        # every list in this project did until 2026-08-28, and why seven fonts
+        # and ninety-seven drawings shipped named nowhere.
+        "the bill of materials stops naming the files a module brings",
+        "internal/legal/spdx.go",
+        "\treturn append(items, embeddedItems(linkedSet(binary.Modules), binary.Packages)...)",
+        "\treturn items",
+        "TestTheSBOMNamesEverythingTheBinariesCarry",
+    ),
+    (
+        # Everything is attached to everything: the document says the command
+        # line binary carries fonts, which it does not - it has no toolkit in
+        # it at all. A bill of materials that describes a file nobody ships is
+        # worse than none, because a compliance process acts on it.
+        "the document says every binary carries every font",
+        "internal/legal/carried.go",
+        "\treturn linkedModules[asset.Module]\n}",
+        "\treturn true\n}",
+        "TestTheSBOMKeepsTheTwoBinariesApart",
+    ),
+    (
+        # A field SPDX requires goes empty. Nothing fails loudly - the document
+        # still parses as JSON, and a reader takes the gap for an answer.
+        "the document loses a field SPDX requires",
+        "internal/legal/spdx.go",
+        "\t\tDataLicense:       \"CC0-1.0\",",
+        "\t\tDataLicense:       \"\",",
+        "TestTheSBOMCarriesTheFieldsSPDXRequires",
+    ),
+    (
+        # The order stops being settled, so two renderings of one build differ.
+        # A release attaches this file and an attestation is made about its
+        # bytes, so a document that moves on its own is one nobody can verify
+        # against anything.
+        "the order of components stops being settled",
+        "internal/legal/spdx.go",
+        "\tsort.Strings(paths)",
+        "\tsort.Strings(paths[:0])",
+        "TestTheSBOMIsTheSameBytesForTheSameInputs",
+    ),
+    (
+        # The gate finds an unaccounted component and passes anyway. This is
+        # the exact shape of a gate that reads like a gate and is not one.
+        "the bill of materials gate stops refusing what it found",
+        ".github/scripts/sbom_gate.py",
+        "    if unaccounted:",
+        "    if unaccounted and False:",
+        "TestTheBillOfMaterialsGateRefusesWhatTheRegistryDoesNotKnow",
+    ),
+    (
+        # A scanner that fell over is treated as a clean tree. Measured in this
+        # repository with a different scanner: four errors, exit code zero and
+        # a file that was not JSON.
+        "an empty scan is treated as a clean scan",
+        ".github/scripts/sbom_gate.py",
+        "        return unusable(\"the scan names no artifact at all, so it scanned nothing\")",
+        "        return 0",
+        "TestAnUnusableScanIsNotACleanScan",
+    ),
+    (
+        # The workflow stops running the gate. The script stays in the tree, the
+        # guards over it stay green, and nothing asks the question on any change
+        # - which is how a probe in this project sat unrun for a day.
+        "the workflow stops running the bill of materials gate",
+        ".github/workflows/ci.yml",
+        "        run: python .github/scripts/sbom_gate.py scan.json ours.spdx.json",
+        "        run: echo the gate is not run here",
+        "TestTheWorkflowScansBothBinariesAndReadsTheReport",
+    ),
+    (
+        # The permission goes away and nothing fails loudly: the step cannot
+        # mint a token, the release goes out, and every other job stays green -
+        # while what it published carries no statement anybody can check.
+        "the release loses the permission an attestation needs",
+        ".github/workflows/release.yml",
+        "      id-token: write",
+        "      id-token: read",
+        "TestTheReleaseAsksForWhatAnAttestationNeeds",
+    ),
+    (
+        # The notes stop telling anybody how to check. A statement nobody knows
+        # about is a statement nobody checks, which is the same as not making
+        # it - except that it looks like diligence.
+        "the release notes stop saying how to check a download",
+        ".github/workflows/release.yml",
+        "            echo \"gh attestation verify  -R ${GITHUB_REPOSITORY}\"",
+        "            echo \"nothing to check here\"",
+        "TestTheReleaseNotesSayHowToCheckWhatWasDownloaded",
+    ),
+    (
+        # The statement stops being about a named list and becomes about
+        # whatever is in a directory, which is how a statement ends up covering
+        # files nobody published and missing ones they did.
+        "the build attests a glob instead of the list of what it made",
+        ".github/workflows/release.yml",
+        "          subject-checksums: incoming/build.sha256",
+        "          subject-path: incoming/*",
+        "TestTheReleaseAttestsWhatItBuilt",
+    ),
+    (
+        # The statement stays in the attestation store and never reaches the
+        # machine that signs - so the signing script has nothing to check the
+        # build against, and signing something you did not check is the whole
+        # attack this ritual is shaped around.
+        "the build's statement stops travelling with the build",
+        ".github/workflows/release.yml",
+        "        run: cp \"${{ steps.provenance.outputs.bundle-path }}\" incoming/build.provenance.sigstore.json",
+        "        run: echo the statement stays in the store",
+        "TestTheReleaseMakesItsDocumentAndHandsItOver",
+    ),
+    (
+        # The defect the four phase split exists to prevent: unsigned Windows
+        # executables on a release page, replaced later by signed ones. Even a
+        # quarter of an hour is long enough for somebody to download one.
+        "the build publishes archives that have not been signed yet",
+        ".github/workflows/release.yml",
+        "          gh release create \"${GITHUB_REF_NAME}\" \"${flags[@]}\"\n",
+        "          gh release create \"${GITHUB_REF_NAME}\" \"${flags[@]}\" incoming/*\n",
+        "TestTheReleaseHandsTheBuildOverInsteadOfPublishingIt",
+    ),
+    (
+        # The question stops naming a workflow and asks about whatever happened
+        # to report. A workflow that never ran then reads exactly like one that
+        # passed, which is the failure this check exists for.
+        "the release asks about any run instead of about CI",
+        ".github/workflows/release.yml",
+        "gh run list --workflow=CI --commit=",
+        "gh run list --commit=",
+        "TestTheReleaseChecksTheTreeAndTheChangelogFirst",
+    ),
+    (
+        # The real bug, put back. Measured on this machine on 2026-08-28:
+        # selecting the certificate by the display name "Code Signing" finds
+        # nothing on a Polish Windows, which calls it "Podpisywanie kodu" - so
+        # the script reports a missing card while the card is plugged in and
+        # working, and the person holding it starts debugging the reader.
+        "the signing script looks for the certificate by name instead of by OID",
+        ".github/scripts/sign_release.py",
+        "CODE_SIGNING_OID = \"1.3.6.1.5.5.7.3.3\"",
+        "CODE_SIGNING_OID = \"Code Signing\"",
+        "TestTheSigningScriptRefusesBeforeItSigns",
+    ),
+    (
+        # The workflow trusts the digest it was handed instead of fetching what
+        # the release holds. Everything it then attests is a statement about
+        # bytes it never saw, which is the difference between an attestation
+        # and a rumour.
+        "the attestation workflow stops checking that the checksums describe the files it fetched",
+        ".github/workflows/attest-release.yml",
+        "          sha256sum -c verify-SHA256SUMS.txt",
+        "          sha256sum verify-SHA256SUMS.txt",
+        "TestTheAttestationWorkflowDoesNotClaimToHaveBuiltAnything",
+    ),
+    (
+        # The pin becomes a name. A name is what a second certificate on the
+        # same machine also has, and the release page looks identical either
+        # way - which is exactly the accident the pin exists to catch.
+        "the certificate is pinned by a name instead of by its bytes",
+        "internal/legal/codesign.go",
+        "const CodeSigningSHA256 = \"47b79ad3cfa53ef846cad03a59148f8c981d0b1196891e48b8c8d7982b10c148\"",
+        "const CodeSigningSHA256 = \"the code signing certificate on the card\"",
+        "TestThePinnedCertificateIsADigestWithADate",
+    ),
+    (
+        "the window binary reaches macOS as a bare binary, which cannot be stapled",
+        ".github/workflows/release.yml",
+        '              "${work}" "tfg-gui" "com.donislawdev.tfg-gui" "${version}"',
+        '              "${work}" "tfg-gui" "com.donislawdev.tfg" "${version}"',
+        "TestTheReleaseWrapsTheMacBinariesInBundles",
+    ),
+    (
+        "a copy of the binary can sit beside the bundle and be rejected by Gatekeeper",
+        ".github/scripts/make_app_bundle.sh",
+        'if [ ! -L "${work}/${binary}" ]; then',
+        'if [ ! -e "${work}/${binary}" ]; then',
+        "TestTheLinkBesideTheBundleCannotBeACopy",
+    ),
+    (
+        "the Apple certificate has no expiry that can be read as a date",
+        "internal/legal/codesign.go",
+        'const AppleDeveloperIDExpires = "2029-10-27"',
+        'const AppleDeveloperIDExpires = "soon"',
+        "TestThePinnedAppleCertificateIsADigestWithADate",
+    ),
+    (
+        "notarisation that came back anything at all counts as success",
+        ".github/scripts/sign_macos.sh",
+        'grep -q "status: Accepted"',
+        'grep -q "status:"',
+        "TestTheMacSigningScriptRefusesOnBothSidesOfSigning",
+    ),
+    (
+        "the release is signed on the card only, and macOS goes out unsigned",
+        ".github/scripts/sign_release.py",
+        "sign_macos(args.tag, work, args.macos_host, args.dry_run)",
+        "pass  # sign_macos removed",
+        "TestTheReleaseWillNotBeSignedOnOneMachineOnly",
+    ),
+    (
+        "the release notes go on telling people the macOS binaries are unsigned",
+        ".github/workflows/release.yml",
+        'echo "**Linux binaries are not signed.** Linux says nothing about that, but you may"',
+        'echo "**macOS and Linux binaries are not signed yet.** Linux says nothing, but you may"',
+        "TestTheReleaseNotesDoNotCallTheMacBinariesUnsigned",
+    ),
+    (
+        "the published release is never read on a Mac, so nobody checks the ticket",
+        ".github/workflows/verify-release.yml",
+        'if spctl --status 2>/dev/null | grep -q "assessments enabled"; then',
+        'if spctl --status 2>/dev/null | grep -q "assessments"; then',
+        "TestThePublishedReleaseIsCheckedOnAMacAsWell",
+    ),
+    (
+        "the files a person checks a download with lose the prefix that sorts them last",
+        ".github/scripts/sign_release.py",
+        'AUX_PREFIX = "verify-"',
+        'AUX_PREFIX = "aaa-"',
+        "TestTheFilesYouCheckADownloadWithSortToTheEnd",
+    ),
+    (
+        "the statement about the signed bytes lands in the middle of the archives",
+        ".github/workflows/attest-release.yml",
+        'name="verify-tfg_${TAG#v}.sbom.sigstore.json"',
+        'name="tfg_${TAG#v}.sbom.sigstore.json"',
+        "TestEveryFileYouCheckADownloadWithCarriesThePrefix",
+    ),
+    (
+        "the workflow that verifies a release can also write to it",
+        ".github/workflows/verify-release.yml",
+        "    runs-on: windows-latest\n    timeout-minutes: 20\n    permissions:\n      contents: read",
+        "    runs-on: windows-latest\n    timeout-minutes: 20\n    permissions:\n      contents: write",
+        "TestThePublishedReleaseIsCheckedTheWayAUserWould",
+    ),
+    (
+        "a menu stops being held to a floor, so it shrinks to the length of its own values and cuts off the placeholder the toolkit puts in it",
+        "internal/gui/parts/ring.go",
+        "\treturn fyne.Max(NumericWidth, box)",
+        "\treturn box",
+        "TestAMenuIsWideEnoughForTheWordsTheToolkitPutsInIt",
+    ),
+    (
+        "the floor a menu is held to drops below the narrowest box beside it, so one setting is drawn shorter on one screen than on another",
+        "internal/gui/parts/ring.go",
+        "fyne.Max(NumericWidth, box)",
+        "fyne.Max(NumericWidth/2, box)",
+        "TestNoMenuIsNarrowerThanTheBoxesItStandsBeside",
+    ),
+    (
+        "a button standing in a row of fields goes back to taking the column, so it is drawn as a panel with a word in the middle",
+        "internal/gui/parts/parts.go",
+        "\treturn container.NewVBox(layout.NewSpacer(), container.NewHBox(o), layout.NewSpacer())",
+        "\treturn container.NewVBox(layout.NewSpacer(), o, layout.NewSpacer())",
+        "TestAButtonInARowOfFieldsIsTheSizeOfAButton",
+    ),
+    (
+        "the macOS bundle stops carrying an icon, so the Finder and the Dock draw the program as a blank page",
+        ".github/scripts/make_app_bundle.sh",
+        'cp "${icon}" "${app}/Contents/Resources/icon.icns"',
+        'true "${icon}" "${app}/Contents/Resources"',
+        "TestTheMacBundleCarriesOurIcon",
+    ),
+    (
+        "the macOS signing script goes back to changing directory, so the paths it was handed stop meaning anything",
+        ".github/scripts/sign_macos.sh",
+        '  codesign -d --extract-certificates="$tmp/cert" "$bundle" >/dev/null 2>&1',
+        '  ( cd "$tmp" && codesign -d --extract-certificates=cert "$bundle" >/dev/null 2>&1 )',
+        "TestTheMacSigningScriptDoesNotDependOnWhereItIsRunFrom",
+    ),
+    (
+        "the attesting half goes back to its own list of what a release holds, which stops agreeing with the checksums",
+        ".github/workflows/attest-release.yml",
+        "          gh release download \"$TAG\" --pattern 'verify-SHA256SUMS.txt'",
+        "          gh release download \"$TAG\" --pattern '*.zip' --pattern '*.tar.gz' --pattern 'verify-SHA256SUMS.txt'",
+        "TestTheAttestingHalfFetchesWhatTheChecksumsName",
+    ),
+    (
+        "one workflow goes back to promising a predicate type that the statement does not carry",
+        ".github/workflows/release.yml",
+        'echo "   --predicate-type https://spdx.dev/Document/v2.3"',
+        'echo "   --predicate-type https://spdx.dev/Document"',
+        "TestEveryWorkflowNamesTheSamePredicateType",
+    ),
+    (
+        "a TIFF directory says the pixels are a different length than the geometry gives",
+        "internal/format/tiff/tiff.go",
+        'entry(tagStripByteCounts, typeLong, 1, uint32(pixelBytes(m.width, m.height)))',
+        'entry(tagStripByteCounts, typeLong, 1, uint32(pixelBytes(m.width, m.height)/2))',
+        "TestEveryFormatSurvivesItsReferenceTool",
+    ),
+    (
+        "a TIFF writes two directory entries out of ascending tag order",
+        "internal/format/tiff/tiff.go",
+        "\tshort(tagImageWidth, uint16(m.width))\n\tshort(tagImageLength, uint16(m.height))",
+        "\tshort(tagImageLength, uint16(m.height))\n\tshort(tagImageWidth, uint16(m.width))",
+        "TestEveryFormatSurvivesItsReferenceTool",
+    ),
+    (
+        "a TIFF points StripOffsets at the padding instead of the pixels",
+        "internal/format/tiff/tiff.go",
+        'entry(tagStripOffsets, typeLong, 1, uint32(pixelsAt(m)))',
+        'entry(tagStripOffsets, typeLong, 1, uint32(header))',
+        "TestEveryFormatSurvivesItsReferenceTool",
+    ),
+    (
+        "a TIFF declares grayscale while writing three samples per pixel",
+        "internal/format/tiff/tiff.go",
+        '\tshort(tagPhotometric, 2) // RGB',
+        '\tshort(tagPhotometric, 1) // RGB',
+        "TestEveryFormatSurvivesItsReferenceTool",
+    ),
+    (
+        "a TIFF writes its colour channels in the wrong order",
+        "internal/format/tiff/tiff.go",
+        "\t\trow[x*3] = uint8((x + off) % 256)\n\t\trow[x*3+1] = uint8((y + off) % 256)\n\t\trow[x*3+2] = uint8((x + y + off) % 256)",
+        "\t\trow[x*3] = uint8((x + y + off) % 256)\n\t\trow[x*3+1] = uint8((y + off) % 256)\n\t\trow[x*3+2] = uint8((x + off) % 256)",
+        "TestOurOwnGeneratorsHaveNotDrifted",
+    ),
+    (
+        "a TIFF picture stops being grown to fill the request",
+        "internal/format/tiff/tiff.go",
+        "\t\treturn w, fill(r.Bytes, w), nil\n\t\tdefault:",
+        "\t\treturn w, minDimension, nil\n\t\tdefault:",
+        "TestOurOwnGeneratorsHaveNotDrifted",
+    ),
+    (
+        "a GIF goes back to writing one frame by default",
+        "internal/format/gif/gif.go",
+        "\tdefaultFrames = 3",
+        "\tdefaultFrames = 1",
+        "TestAGeneratedGifReallyCarriesAnAnimation",
+    ),
+    (
+        "a GIF puts the marker of every frame in the same place",
+        "internal/format/gif/animation.go",
+        "\tx = (width - side) * i / frames",
+        "\tx = (width - side) * i / (frames * 64)",
+        "TestAGeneratedGifReallyCarriesAnAnimation",
+    ),
+    (
+        "a GIF stops asking for a global colour table, so every frame carries its own",
+        "internal/format/gif/animation.go",
+        "\t\tConfig: image.Config{ColorModel: base.Palette, Width: m.width, Height: m.height},",
+        "\t\tConfig: image.Config{Width: m.width, Height: m.height},",
+        "TestAGeneratedGifReallyCarriesAnAnimation",
+    ),
+    (
+        "a GIF animation plays once instead of looping",
+        "internal/format/gif/animation.go",
+        "\t\tLoopCount: 0,",
+        "\t\tLoopCount: -1,",
+        "TestAGeneratedGifReallyCarriesAnAnimation",
+    ),
+    (
+        "a still GIF is quietly animated too, so there is no way back to the older bytes",
+        "internal/format/gif/animation.go",
+        "\tif m.frames <= 1 {",
+        "\tif m.frames <= 0 {",
+        "TestAStillGifIsStillAvailableAndSaysSo",
+    ),
+    (
+        "a WebP writes its colour channels in the wrong order",
+        "internal/format/webp/vp8l.go",
+        "\t\t\tw.write(reverseByte(px[x*3+1]), 8)\n\t\t\tw.write(reverseByte(px[x*3]), 8)",
+        "\t\t\tw.write(reverseByte(px[x*3]), 8)\n\t\t\tw.write(reverseByte(px[x*3+1]), 8)",
+        "TestOurOwnGeneratorsHaveNotDrifted",
+    ),
+    (
+        "a WebP miscounts its own header, so the chunk promises a length the stream does not reach",
+        "internal/format/webp/vp8l.go",
+        "\treturn (headerBits+7)/8 + pixels*bitsPerPixel/8",
+        "\treturn (headerBits+7)/8 + pixels*bitsPerPixel/8 + 1",
+        "TestEveryFormatHitsTheOrderedSizeExactly",
+    ),
+    (
+        "a WebP drops the odd byte after the payload, so half of all sizes stop being reachable",
+        "internal/format/webp/webp.go",
+        "\t\tm.tail = (delta - chunkHeader - 4) % 2",
+        "\t\tm.tail = (delta - chunkHeader - 4) * 0",
+        "TestEveryFormatHitsTheOrderedSizeExactly",
+    ),
+    (
+        "a WebP declares a green code with no room for the length prefixes",
+        "internal/format/webp/vp8l.go",
+        "\twriteFlatCode(w, greenAlphabet)",
+        "\twriteFlatCode(w, literals)",
+        "TestEveryFormatSurvivesItsReferenceTool",
+    ),
+    (
+        "a WebP lies about how long its RIFF payload is",
+        "internal/format/webp/webp.go",
+        "\t\t{num: uint32(4 + body)},",
+        "\t\t{num: uint32(body)},",
+        "TestEveryFormatSurvivesItsReferenceTool",
+    ),
+    (
+        "the AVIF ladder claims a picture fits a file far too small to hold it, so the size it promised cannot be met",
+        "internal/format/avif/avif.go",
+        "\t{640, 480, 16389}, {320, 240, 6792}, {256, 256, 4890}, {160, 120, 3392},",
+        "\t{640, 480, 311}, {320, 240, 6792}, {256, 256, 4890}, {160, 120, 3392},",
+        "TestAvifDrawsAPictureThatGrowsWithTheFileAndAlwaysFits",
+    ),
+    (
+        "planning codes the picture again, so previewing a large run costs what the run costs",
+        "internal/format/avif/avif.go",
+        "\treturn memo{width: pick.width, height: pick.height, quality: q, seed: r.Seed, label: label}, nil",
+        "\treturn coded(memo{width: pick.width, height: pick.height, quality: q, seed: r.Seed, label: label})",
+        "TestAvifPlanningDoesNotCodeThePicture",
+    ),
+    (
+        "the build tags stop naming the tag that keeps the encoder inside its buffers",
+        ".github/build-tags",
+        "noasm",
+        "keepthecrash",
+        "TestTheBuildTagsFileNamesTheTagThatKeepsTheEncoderInsideItsBuffers",
+    ),
+    (
+        "a workflow command compiles this project without the build tags, so CI stops testing what the release ships",
+        ".github/workflows/ci.yml",
+        "        run: go vet -tags \"$(cat .github/build-tags)\" ./...",
+        "        run: go vet ./...",
+        "TestEveryWorkflowCommandThatBuildsUsPassesTheBuildTags",
+    ),
+    (
+        "the window opens on a format picked by position rather than on the first one the registry hands it",
+        "internal/gui/window/generate.go",
+        "\t\tg.formatPick.SetSelected(ids[0])",
+        "\t\tg.formatPick.SetSelected(ids[len(ids)-1])",
+        "TestTheFormatAScreenOpensOnIsTheFirstOneRegistered",
+    ),
+    (
+        "a log setting arriving at its default counts as one somebody asked for, which puts every shape but the default out of reach from a window",
+        "internal/format/logfile/options.go",
+        "\tif !ok || v == def {",
+        "\tif !ok {",
+        "TestEveryLogShapeIsReachableWithEverySettingSent",
+    ),
+    (
+        "the syslog process id is counted as four digits always, so one closing entry in ten is a byte short",
+        "internal/format/logfile/shapes.go",
+        "pidWidth(pid)+len(\"]: \")",
+        "4+len(\"]: \")",
+        "TestEveryLogShapeHitsTheSizeAcrossSeeds",
+    ),
+    (
+        "a menu sitting on its declared default counts as chosen, so the folded line lists every setting a format declares",
+        "internal/gui/parts/property.go",
+        "return sel.Selected != \"\" && sel.Selected != p.Default",
+        "return sel.Selected != \"\"",
+        "TestTheFoldedLineNamesWhatWasChosenAndNotTheDefaults",
+    ),
+    (
+        "the log clock stops advancing, so every entry happens at the same instant again",
+        "internal/format/logfile/clock.go",
+        "c.at = c.at.Add(c.step)",
+        "c.at = c.at.Add(0)",
+        "TestALogClockAdvancesByOneTickPerEntry",
+    ),
+    (
+        "the log clock is not put back for the entry built only to be measured, so the file skips a tick",
+        "internal/format/logfile/clock.go",
+        "func (c *clock) back() { c.at = c.at.Add(-c.step) }",
+        "func (c *clock) back() { c.at = c.at.Add(0) }",
+        "TestALogClockAdvancesByOneTickPerEntry",
+    ),
+    (
+        "timestamps=fixed starts advancing too, so the way back from the breaking change stops being one",
+        "internal/format/logfile/clock.go",
+        "if o.advancing {",
+        "if true {",
+        "TestALogWithFixedTimestampsHoldsOneInstant",
+    ),
+    (
+        "a log setting that can do nothing for the chosen shape is accepted in silence instead of refused",
+        "internal/format/logfile/options.go",
+        "\tif s.web {\n\t\treturn nil\n\t}",
+        "\tif true {\n\t\treturn nil\n\t}",
+        "TestALogRefusesASettingThatWouldChangeNothing",
+    ),
+    (
+        "the JSON log label goes back to a hash comment, the one line no JSON reader will take",
+        "internal/format/logfile/shapes.go",
+        "shortest: shortestJSONLine, label: jsonLabel}",
+        "shortest: shortestJSONLine, label: hashLabel}",
+        "TestTheLogLabelIsALineItsOwnReaderAccepts",
+    ),
+    (
+        "a one item list of log settings spends a draw anyway, which shifts every entry and breaks the pinned way back",
+        "internal/format/logfile/address.go",
+        "\tif len(xs) == 1 {",
+        "\tif len(xs) == 0 {",
+        "TestOurOwnGeneratorsHaveNotDrifted",
+    ),
+    (
+        "the JPEG XL ladder claims a picture fits a file far too small to hold it, so the size it promised cannot be met",
+        "internal/format/jxl/jxl.go",
+        "	{640, 480, 22762}, {320, 240, 7949}, {256, 256, 6485}, {160, 120, 3542},",
+        "	{640, 480, 147}, {320, 240, 7949}, {256, 256, 6485}, {160, 120, 3542},",
+        "TestJxlDrawsAPictureThatGrowsWithTheFileAndAlwaysFits",
+    ),
+    (
+        "a JPEG XL rung declares a ceiling below what that rung actually codes to",
+        "internal/format/jxl/jxl.go",
+        "	{80, 60, 1267}, {40, 30, 731}, {20, 15, 452}, {8, 6, 222},",
+        "	{80, 60, 100}, {40, 30, 731}, {20, 15, 452}, {8, 6, 222},",
+        "TestEveryJxlRungStillFitsInsideItsDeclaredCeiling",
+    ),
+    (
+        "JPEG XL planning codes the picture again, so previewing a large run costs what the run costs",
+        "internal/format/jxl/jxl.go",
+        "	return memo{width: pick.width, height: pick.height, quality: q, seed: r.Seed, label: label}, nil",
+        "	return coded(memo{width: pick.width, height: pick.height, quality: q, seed: r.Seed, label: label})",
+        "TestJxlPlanningDoesNotCodeThePicture",
+    ),
+    (
+        "a named JPEG XL quality trusts a ceiling table measured at a different one, so the picture does not fit the file",
+        "internal/format/jxl/jxl.go",
+        "	if _, named := r.Properties[\"quality\"]; named && q != defaultQuality {",
+        "	if _, named := r.Properties[\"quality\"]; named && q == defaultQuality {",
+        "TestJxlStillFitsWhenTheQualityIsNotTheOneTheCeilingsWereMeasuredAt",
+    ),
+    (
+        "the JPEG XL file type box stops naming the compatible brand, which libjxl refuses and the Go decoder does not",
+        "internal/format/jxl/jxl.go",
+        "	'j', 'x', 'l', ' ', // compatible brand",
+        "	'n', 'o', 'n', 'e', // compatible brand",
+        "TestEveryFormatSurvivesItsReferenceTool",
+    ),
+    (
+        "the JPEG XL codestream goes into a partial codestream box without the index one needs, so no reader finds a picture",
+        "internal/format/jxl/jxl.go",
+        "	if err := isobmff.WriteBoxHeader(w, \"jxlc\", int64(len(coded))); err != nil {",
+        "	if err := isobmff.WriteBoxHeader(w, \"jxlp\", int64(len(coded))); err != nil {",
+        "TestEveryFormatSurvivesItsReferenceTool",
+    ),
+    (
+        "a named quality trusts a ceiling table measured at a different one, so the picture does not fit the file",
+        "internal/format/avif/avif.go",
+        "\tif _, named := r.Properties[\"quality\"]; named && q != defaultQuality {",
+        "\tif _, named := r.Properties[\"quality\"]; named && q == defaultQuality {",
+        "TestAvifStillFitsWhenTheQualityIsNotTheOneTheCeilingsWereMeasuredAt",
+    ),
+    (
+        "the deepest nesting an archive offers goes past what a tar header can hold",
+        "internal/format/archive/layout.go",
+        "maxDepth = 50",
+        "maxDepth = 200",
+        "TestTheDeepestPathEveryFormatCanMakeStillFitsAUstarHeader",
+    ),
+    (
+        "the ceiling on nesting stops being one tar actually enforces",
+        "internal/format/archive/layout.go",
+        "return maxDepth }",
+        "return maxDepth * 10 }",
+        "TestAPathOneStepPastTheCeilingIsRefusedByTar",
+    ),
+    (
+        "an archive nobody asked to nest starts out nested, moving the bytes of every archive ever written",
+        "internal/format/archive/layout.go",
+        "defaultDepth = 0",
+        "defaultDepth = 1",
+        "TestAnArchiveNobodyAskedToNestIsStillFlat",
+    ),
+    (
+        "asking for directory entries in a flat archive stops being refused and quietly does nothing",
+        "internal/format/archive/layout.go",
+        "if dirs && depth == 0 {",
+        "if dirs && depth == 0 && false {",
+        "TestAskingForDirectoryEntriesInAFlatArchiveNamesBothSettings",
+    ),
+    (
+        "the directories an archive names come innermost first, so a reader meets a child before its parent",
+        "internal/format/archive/layout.go",
+        "Layout{Depth: i}.Path(\"\")",
+        "Layout{Depth: l.Depth + 1 - i}.Path(\"\")",
+        "TestTheDirectoriesAnArchiveNamesComeOutermostFirst",
+    ),
+    (
+        "a switch on the window is drawn and then ignored, so the setting never reaches the file",
+        "internal/gui/parts/property.go",
+        "Value:   func() string { return strconv.FormatBool(check.Checked) },",
+        "Value:   func() string { return strconv.FormatBool(check.Checked && false) },",
+        "TestASwitchOnTheWindowReachesTheFileItDescribes",
+    ),
+    (
+        "a compressed archive stops giving back what the compressor freed, so it comes out short",
+        "internal/format/zip/filler.go",
+        "size := m.fillerSize + freed()",
+        "size := m.fillerSize + freed()*0",
+        "TestACompressedArchiveStillHitsTheSizeToTheByte",
+    ),
+    (
+        "asking for compression quietly stores everything and only grows the padding",
+        "internal/format/targz/size.go",
+        "gzip.NewWriterLevel(w, m.squeeze.Level)",
+        "gzip.NewWriterLevel(w, m.squeeze.Level*0)",
+        "TestAskingForCompressionActuallyCompresses",
+    ),
+    (
+        "compression stops being refused when the size is left to the contents, so a length is planned that only compressing could give",
+        "internal/format/archive/compression.go",
+        "if r.SizeFromContents {",
+        "if r.SizeFromContents && false {",
+        "TestCompressionWithASizeFromTheContentsIsRefusedNamingBoth",
+    ),
+    (
+        "compression stops being refused beside a password, so a locked entry would have to be held in memory whole",
+        "internal/format/archive/compression.go",
+        "if locked {",
+        "if locked && false {",
+        "TestCompressionWithAPasswordIsRefusedNamingBoth",
+    ),
+    (
+        "an archive nobody asked to compress starts compressing, moving the bytes of every archive ever written",
+        "internal/format/archive/compression.go",
+        "return Squeeze{Name: CompressNone, Level: flate.NoCompression}, nil",
+        "return Squeeze{Name: CompressDefault, Level: levels[CompressDefault]}, nil",
+        "TestAnArchiveNobodyAskedToCompressIsStored",
+    ),
+    (
+        "the plain log draws from every severity this format knows rather than from the mix that was chosen",
+        "internal/format/logfile/shapes.go",
+        "func appendPlain(dst []byte, st *state, want int64) []byte {\n\tlevel := pick(st.rng, st.opt.levels)",
+        "func appendPlain(dst []byte, st *state, want int64) []byte {\n\tlevel := pick(st.rng, levelSets[\"realistic\"])",
+        "TestTheLogSeverityMixIsDrawnAndSetsTheMinimum",
+    ),
+    (
+        "the JSON log line draws from every severity rather than from the mix that was chosen",
+        "internal/format/logfile/shapes.go",
+        "func appendJSONLine(dst []byte, st *state, want int64) []byte {\n\tlevel := pick(st.rng, st.opt.levels)",
+        "func appendJSONLine(dst []byte, st *state, want int64) []byte {\n\tlevel := pick(st.rng, levelSets[\"realistic\"])",
+        "TestTheLogSeverityMixIsDrawnAndSetsTheMinimum",
+    ),
+    (
+        "the shortest log entry leaves room for the longest severity of ALL of them, so a quiet log announces a floor it can beat",
+        "internal/format/logfile/address.go",
+        "func longestLevel(o options) int  { return longest(o.levels) }",
+        "func longestLevel(o options) int  { return longest(levelSets[\"realistic\"]) }",
+        "TestTheLogSeverityMixIsDrawnAndSetsTheMinimum",
+    ),
+    (
+        "a severity mix asked for beside a shape that carries no severity is accepted and then does nothing",
+        "internal/format/logfile/options.go",
+        "chosen && !o.shape.levelled",
+        "chosen && !o.shape.levelled && false",
+        "TestALogRefusesASettingThatWouldChangeNothing",
+    ),
+    (
+        "the padding drops a comma whatever the dialect, so a semicolon table never exercises its own quoting",
+        "internal/format/csvfile/csv.go",
+        "both := string(sep) + \" \"",
+        "both := \", \"",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        "the fields of a row are separated by a comma whatever the dialect says",
+        "internal/format/csvfile/csv.go",
+        "sep := r.dia.sep",
+        "sep := byte(',')",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        "a row ends with a newline whatever the dialect says, so crlf never reaches the file",
+        "internal/format/csvfile/csv.go",
+        "return append(dst, r.dia.eol...)",
+        "return append(dst, \"\\n\"...)",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        "the header row is written even when it was turned off",
+        "internal/format/csvfile/csv.go",
+        "if m.dia.header {",
+        "if m.dia.header || true {",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        "the row width counts one byte of ending whatever the dialect, so a crlf table announces a floor it cannot meet",
+        "internal/format/csvfile/csv.go",
+        "total := int64(d.columns-1) /* separators */ + int64(len(d.eol)) +",
+        "total := int64(d.columns-1) /* separators */ + int64(1) +",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # The floor pays for the quotes only when every field carries them.
+        # Charge nothing and quote_style all announces a floor twelve bytes
+        # below the shortest row it can write, so the closing row overshoots
+        # the length it was handed.
+        "the floor is worked out as if no field were ever quoted",
+        "internal/format/csvfile/dialect.go",
+        "return 2 * columns",
+        "return 0 * columns",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # A header is a row of fields like any other. Leave it bare under
+        # quote_style all and the file is still the right size and still
+        # parses - only the one row disagrees with the setting that wrote it.
+        "the header is left bare while every row below it is quoted",
+        "internal/format/csvfile/dialect.go",
+        "if d.quotes.everyField {",
+        "if d.quotes.everyField && false {",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # quote_style none has to change the CONTENT, not only the punctuation.
+        # Keep dropping separators into an unquoted description and every row
+        # of a none table is ragged, at exactly the right size.
+        "a description under quote_style none keeps carrying separators it has no quotes for",
+        "internal/format/csvfile/csv.go",
+        "if carries && i%3 == 0 {",
+        "if (carries || true) && i%3 == 0 {",
+        "TestEveryCSVDialectIsWellFormed",
+    ),
+    (
+        # The same defect in the padding rather than in the phrase, which is a
+        # different function and a different guard - the closing row is the
+        # only one the filler writes.
+        "the padding under quote_style none keeps carrying separators it has no quotes for",
+        "internal/format/csvfile/csv.go",
+        "if carries && i%4 == 0 {",
+        "if (carries || true) && i%4 == 0 {",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # The closing row under minimal decides between quoted and bare by
+        # MEASURING the description it built. Skip the measurement and it is
+        # never quoted, so the padding stops carrying the separator at all -
+        # right size, valid CSV, and the quoted path is never exercised again.
+        "the closing row never asks whether its description earned its quotes",
+        "internal/format/csvfile/csv.go",
+        "if q.separatorsInDescription && room >= 2 {",
+        "if q.separatorsInDescription && room >= 2 && false {",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # The checker is TOLD the width and stops comparing it, so it becomes a
+        # rubber stamp on the one axis where every value produces a well formed
+        # file. A nine column table called eight would sail through.
+        "the structural checker accepts whatever width it is told, so it judges nothing",
+        "internal/oracle/strict.py",
+        "if wanted is not None and columns != wanted:",
+        "if wanted is not None and columns != wanted and False:",
+        "TestEveryCSVWidthIsWellFormed",
+    ),
+    (
+        # The six columns this format has always written come out in a
+        # different order. Same count, same widths, same size, and every
+        # reader still parses it - somebody's pinned hash is simply wrong now.
+        "the six original columns come out in a different order",
+        "internal/format/csvfile/dialect.go",
+        'var baseColumns = []string{"id", "name", "email", "amount", "created"}',
+        'var baseColumns = []string{"name", "id", "email", "amount", "created"}',
+        "TestTheDefaultCSVStillHasItsSixOriginalColumns",
+    ),
+    (
+        # The width is stored and the writer ignores it: every table comes out
+        # six columns wide whatever was ordered. Right size, parses everywhere,
+        # every row agreeing with every other - only a guard counting the
+        # fields against the number asked for sees it.
+        "the table is six columns wide whatever width was ordered",
+        "internal/format/csvfile/csv.go",
+        "for at := 0; at < r.dia.columns-1; at++ {",
+        "for at := 0; at < defaultColumns-1; at++ {",
+        "TestTheCSVColumnCountIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # The header keeps the width and the rows lose it, which is the shape
+        # where a reader reports a ragged table rather than a narrow one.
+        "the header names a different number of columns than the rows carry",
+        "internal/format/csvfile/dialect.go",
+        "func columnNamesFor(columns int) []string {",
+        "func columnNamesFor(columns int) []string {\n\tcolumns++",
+        "TestTheCSVColumnCountIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # The floor is worked out for six columns and handed to every width, so
+        # a wide table announces a minimum it cannot meet and a narrow one
+        # refuses sizes it could have written.
+        "the floor is worked out for one width and handed to the rest",
+        "internal/format/csvfile/csv.go",
+        "for at := 0; at < d.columns-1; at++ {",
+        "for at := 0; at < defaultColumns-1; at++ {",
+        "TestTheCSVColumnCountIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # The description stops being last. The row is the right length and the
+        # padding still reaches the byte - it just lands in the middle of the
+        # record, where a stretched field is not what the format promised.
+        "the description is no longer the last column",
+        "internal/format/csvfile/dialect.go",
+        'return append(out, "description")',
+        'return append([]string{"description"}, out...)',
+        "TestTheCSVColumnCountIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        # The manifest reports the count this format started with rather than
+        # the one the run used, so a script reading the manifest is told the
+        # wrong width about a perfectly correct file.
+        "the manifest names six columns whatever width was written",
+        "internal/format/csvfile/csv.go",
+        '"columns":     d.columns,',
+        '"columns":     defaultColumns,',
+        "TestTheCSVManifestRecordsTheDialectAsFacts",
+    ),
+    (
+        # The exact shape that cost two red runs: a whole tree test run with no
+        # timeout of its own, so Go's ten minutes a package decides instead of
+        # the twenty the job declares. The run then PANICS out of whichever test
+        # was running, which reads as a broken test rather than a busy runner.
+        "a workflow runs the whole suite without stating a timeout",
+        ".github/workflows/ci.yml",
+        './... -count=1 -timeout 18m\n\n      - name: build the command line binary',
+        './... -count=1\n\n      - name: build the command line binary',
+        "TestEveryWholeTreeTestRunStatesItsOwnTimeout",
+    ),
+    (
+        # A setting renamed in the registry and not in the README is exactly the
+        # drift that left log saying "none" while it carried eight settings, and
+        # zip listing three of its eight. The page still reads perfectly - it is
+        # simply describing a build that no longer exists.
+        "a setting is renamed in the registry and the README keeps the old name",
+        "internal/format/csvfile/dialect.go",
+        "Name: QuoteStyle, Kind: format.PropertyChoice,",
+        "Name: QuoteStyle + \"_v2\", Kind: format.PropertyChoice,",
+        "TestTheReadmeSettingsTableAgreesWithTheRegistry",
+    ),
+    (
+        # The other half: a format the registry has and the first page a visitor
+        # reads does not. jxl arrived this way on 2026-08-31 and sat unlisted.
+        "a format is registered under a name the README's own list does not carry",
+        "internal/format/jxl/jxl.go",
+        'ID:          "jxl",',
+        'ID:          "jxl2",',
+        "TestTheReadmeListsEveryFormatItShips",
+    ),
+    (
+        # The manifest is the half a test suite reads. Write the default
+        # whatever was asked for and a run under all or none is described as
+        # a run under minimal, with the file itself perfectly correct.
+        "the manifest names the default quote style whatever was asked for",
+        "internal/format/csvfile/csv.go",
+        '"quote_style": d.quotes.id,',
+        '"quote_style": defaultDialect().quotes.id,',
+        "TestTheCSVManifestRecordsTheDialectAsFacts",
+    ),
+    (
+        "the floor pays for a header that is not there",
+        "internal/format/csvfile/dialect.go",
+        "if !d.header {\n\t\treturn 0\n\t}",
+        "if !d.header && false {\n\t\treturn 0\n\t}",
+        "TestTheCSVDialectIsInTheFileAndMovesTheFloor",
+    ),
+    (
+        "the manifest names the separator with the recipe's word instead of the character that is in the file",
+        "internal/format/csvfile/csv.go",
+        "\"separator\":   string(d.sep),",
+        "\"separator\":   d.delimiterID,",
+        "TestTheCSVManifestRecordsTheDialectAsFacts",
+    ),
+    (
+        "the structural checker splits on a comma whatever dialect it was told, so it judges the wrong table",
+        "internal/oracle/strict.py",
+        "elif ch == sep:",
+        "elif ch == \",\":",
+        "TestEveryCSVDialectIsWellFormed",
+    ),
+    (
+        "the gzip framing is measured a byte per block out, so every tar.gz misses its planned length",
+        "internal/format/targz/framing.go",
+        "f := framing{perBlock: two - one}",
+        "f := framing{perBlock: two - one + 1}",
+        "TestTarGzSizeArithmeticSurvivesEveryShape",
+    ),
+    (
+        "the gzip framing keeps the block cost in its base, so the arithmetic is out by five on every archive",
+        "internal/format/targz/framing.go",
+        "f.base = one - f.perBlock",
+        "f.base = one",
+        "TestTarGzSizeArithmeticSurvivesEveryShape",
+    ),
+    (
+        "the check that the framing model is a line asks about the wrong point, so it refuses a build that is fine",
+        "internal/format/targz/framing.go",
+        "if want := f.base + 3*f.perBlock; three != want {",
+        "if want := f.base + 2*f.perBlock; three != want {",
+        "TestTarGzSizeArithmeticSurvivesEveryShape",
+    ),
+    (
+        "the compressed archive gives back only the overshoot, so the walk steps across a 512 byte staircase and never lands",
+        "internal/format/targz/compress.go",
+        "next := filler - (2 - deficit) - tarBlock",
+        "next := filler - (2 - deficit)",
+        "TestACompressedArchiveStillHitsTheSizeToTheByte",
+    ),
+    (
+        # The pattern names no version. It named 1.176.0 until 2026-09-16 and
+        # went stale on the very first Dependabot bump of that file (#102),
+        # which is the bump this file exists to receive - an entry that goes
+        # stale on the event it guards against would go stale every week.
+        # ">=" keeps the line a valid requirement and stops being a pin, which
+        # is what the guard asks about.
+        "the semgrep pin loses its version, so somebody else's release becomes a red build here",
+        ".github/requirements-semgrep.txt",
+        "\nsemgrep==",
+        "\nsemgrep>=",
+        "TestCIRunsSemgrepAndDecidesFromTheReportRatherThanTheExitCode",
+    ),
+    (
+        "the registry folds a choice again, so a value the declaration does not spell reaches the generator",
+        "internal/format/format.go",
+        "if raw == c {",
+        "if strings.EqualFold(raw, c) {",
+        "TestAValueTheDeclarationDoesNotSpellIsRefusedByTheRegistry",
+    ),
+    (
+        "the registry folds a boolean again, so TRUE is somebody else's problem three layers down",
+        "internal/format/format.go",
+        "\tswitch raw {",
+        "\tswitch strings.ToLower(raw) {",
+        "TestAValueTheDeclarationDoesNotSpellIsRefusedByTheRegistry",
+    ),
+    (
+        "an owner the archive does not know falls through to no owner at all, the way it did before",
+        "internal/format/archive/ownership.go",
+        "\tdefault:",
+        "\tcase \"a value no declaration contains\":",
+        "TestAnOwnerTheArchiveDoesNotKnowIsRefusedRatherThanDropped",
+    ),
+    (
+        "the manifest reports the owner the archive would have had anyway, so asking for root leaves no trace",
+        "internal/format/targz/targz.go",
+        "archive.EntryOwner: m.own.Stated.Owner,",
+        "archive.EntryOwner: archive.OwnerUnset,",
+        "TestTheArchiveManifestSaysWhoOwnsTheEntries",
+    ),
+    (
+        "the manifest reports the default mode whatever was asked for",
+        "internal/format/targz/targz.go",
+        "archive.EntryMode:  m.own.Stated.Mode,",
+        "archive.EntryMode:  \"644\",",
+        "TestTheArchiveManifestSaysWhoOwnsTheEntries",
+    ),
+    (
+        "the archive package goes back to writing out the list of compression words by hand",
+        "internal/format/archive/compression.go",
+        "Reason: axes[Compression].Allows(raw),",
+        'Reason: "it takes one of: " + CompressBest + ", " + CompressDefault + ", " + CompressFast + ", " + CompressNone,',
+        "TestTheSentenceNamingWhichWordsASettingTakesIsWrittenInOnePlace",
+    ),
+    (
+        "an archive refuses an owner in words of its own rather than the declaration's",
+        "internal/format/archive/ownership.go",
+        "Reason: axes[EntryOwner].Allows(raw),",
+        'Reason: "it is not an owner this build knows",',
+        "TestAnArchiveRefusesAValueInTheWordsTheRegistryBuilds",
+    ),
+    (
+        "the reader stops recognising an owner the declaration still offers",
+        "internal/format/archive/ownership.go",
+        "case OwnerUser:",
+        'case OwnerUser + "-gone":',
+        "TestEveryWordTheArchiveDeclarationOffersIsOneItsReaderAccepts",
+    ),
+    (
+        "a check of a published release goes back to stopping the job, so the checks behind it are never asked",
+        ".github/workflows/verify-release.yml",
+        "        id: notes\n        continue-on-error: true",
+        "        id: notes",
+        "TestNoReleaseCheckCanHideTheOnesBehindIt",
+    ),
+    (
+        "the release verdict reads conclusion, which continue-on-error rewrites to success, so it passes whatever happened",
+        ".github/workflows/verify-release.yml",
+        "steps.notes.outcome",
+        "steps.notes.conclusion",
+        "TestNoReleaseCheckCanHideTheOnesBehindIt",
+    ),
+    (
+        "the release verdict stops running after a red check, so the job ends silently on the first failure",
+        ".github/workflows/verify-release.yml",
+        "        if: always()",
+        "        if: success()",
+        "TestNoReleaseCheckCanHideTheOnesBehindIt",
+    ),
+    (
+        "the check of a published release stops asking whether the page says how to verify a download",
+        ".github/workflows/verify-release.yml",
+        "bash .github/scripts/note_says.sh notes.md || {",
+        "true || {",
+        "TestWhatAReleaseNoteHasToSayIsDecidedInOnePlace",
+    ),
+    (
+        "the release build stops holding the note it generates to what a release note has to say",
+        ".github/workflows/release.yml",
+        "bash .github/scripts/note_says.sh notes.md",
+        "true",
+        "TestWhatAReleaseNoteHasToSayIsDecidedInOnePlace",
+    ),
+    (
+        "the generated release note names a predicate type the list does not ask for, so the published page tells people the wrong command",
+        ".github/workflows/release.yml",
+        'echo "   --predicate-type https://spdx.dev/Document/v2.3"',
+        'echo "   --predicate-type https://spdx.dev/Document"',
+        "TestTheGeneratedReleaseNoteSaysEverythingTheListAsksFor",
+    ),
+    (
+        "a comment in the repository is written in Polish, which D9 forbids and no character based check can see",
+        "internal/format/archive/compression.go",
+        "\ts := Squeeze{Name: raw, Level: level}",
+        "\t// Tutaj jest wynik, ktory oddajemy dalej.\n\ts := Squeeze{Name: raw, Level: level}",
+        "TestNoCommentInTheRepositoryIsWrittenInPolish",
+    ),
+    (
+        "the social card changes wording and the committed picture of it stays as it was",
+        "web/templates/social.html",
+        "same bytes every run",
+        "identical bytes every run",
+        "TestTheSocialPictureShowsTheCardAsItIsNow",
+    ),
+    (
+        "generate goes back to planning with a context nobody can finish, so Ctrl+C waits for the whole plan",
+        "internal/cli/generate.go",
+        "engine.PlanContext(ctx, targets, opt)",
+        "engine.Plan(targets, opt)",
+        "TestPlanningStopsBeforeItWalksToTheFarTarget",
+    ),
+    (
+        "validate goes back to planning with a context nobody can finish",
+        "internal/cli/recipecmd.go",
+        "engine.PlanContext(ctx, targets, planningOptions(rec))",
+        "engine.Plan(targets, planningOptions(rec))",
+        "TestPlanningFromTheCommandLineCanBeStopped",
+    ),
+    (
+        "preset show goes back to planning its budget with a context nobody can finish",
+        "internal/cli/preset.go",
+        "engine.PlanContext(ctx, targets, engine.Options{",
+        "engine.Plan(targets, engine.Options{",
+        "TestPlanningFromTheCommandLineCanBeStopped",
+    ),
+    (
+        "a stopped validate falls through to the machine readable report and calls the recipe invalid",
+        "internal/cli/recipecmd.go",
+        "stopped := errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)",
+        "stopped := false && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded))",
+        "TestAStoppedValidateDoesNotCallTheRecipeInvalid",
+    ),
+    (
+        "a zip ordered at exactly its bare size takes the early return even when it is squeezed, so nothing gives the freed bytes back",
+        "internal/format/zip/zip.go",
+        "case target == bare && !m.squeeze.On():",
+        "case target == bare:",
+        "TestACompressedZipIsEitherWrittenAtItsSizeOrRefusedInWords",
+    ),
+    (
+        "a filler that would have to be shorter than nothing writes nothing instead of saying the size is impossible",
+        "internal/format/zip/filler.go",
+        "if size < 0 {",
+        "if false && size < 0 {",
+        "TestACompressedZipIsEitherWrittenAtItsSizeOrRefusedInWords",
+    ),
+    (
+        "a recipe stops being checked for how deeply it nests, so 40 kB can ask for as much heap as the machine has",
+        "internal/recipe/recipe.go",
+        "if depth := nestingDepth(src); depth > MaxNestingDepth {",
+        "if depth := nestingDepth(src); false && depth > MaxNestingDepth {",
+        "TestAHostileRecipeCannotHangTheReader",
+    ),
+    (
+        "the depth scan counts brackets again and stops seeing block lists",
+        "internal/recipe/limits.go",
+        "if d := flow + len(dashes); d > deepest {",
+        "if d := flow; d > deepest {",
+        "TestAHostileRecipeCannotHangTheReader",
+    ),
+    (
+        "a dash further right no longer opens a sequence inside the last one",
+        "internal/recipe/limits.go",
+        "if len(open) == 0 || open[len(open)-1] < column {",
+        "if false && (len(open) == 0 || open[len(open)-1] < column) {",
+        "TestAHostileRecipeCannotHangTheReader",
+    ),
+    (
+        "the depth walk stops counting the collections that close, so ordinary nesting reads as unbounded",
+        "internal/recipe/limits.go",
+        "case token.SequenceEndType, token.MappingEndType:",
+        "case token.SequenceEndType:",
+        "TestAHostileRecipeCannotHangTheReader",
+    ),
+    (
+        "regenerating goes back to rewriting every stored screen",
+        "internal/guard/screenpixels_test.go",
+        "return !v.accepts",
+        "return !v.accepts || true",
+        "TestRegeneratingAScreenLeavesTheUnchangedOnesAlone",
+    ),
+    (
+        "regenerating stops writing anything at all",
+        "internal/guard/screenpixels_test.go",
+        "return !v.accepts",
+        "return !v.accepts && false",
+        "TestRegeneratingAScreenLeavesTheUnchangedOnesAlone",
+    ),
+    (
+        "a stored picture showing something else is accepted anyway",
+        "internal/guard/screenpixels_test.go",
+        "v.accepts = v.worst <= pixelTolerance()",
+        "v.accepts = v.worst <= pixelTolerance() || true",
+        "TestRegeneratingAScreenLeavesTheUnchangedOnesAlone",
+    ),
+    (
+        "a stored widget tree is taken as standing whatever it says",
+        "internal/guard/screenpixels_test.go",
+        "return err == nil && want == markup",
+        "return err == nil && want == markup || true",
+        "TestRegeneratingAScreenLeavesTheUnchangedOnesAlone",
+    ),
+    (
+        "a changelog link definition drifts away from the section it names",
+        "CHANGELOG.md",
+        "[0.3.0]: https://github.com/donislawdev/TestingFilesGenerator/compare/v0.2.0...v0.3.0",
+        "[0.3.0-typo]: https://github.com/donislawdev/TestingFilesGenerator/compare/v0.2.0...v0.3.0",
+        "TestEveryChangelogVersionHasItsLinkDefinition",
+    ),
+    (
+        "Unreleased is left comparing from the release before the newest one",
+        "CHANGELOG.md",
+        "[Unreleased]: https://github.com/donislawdev/TestingFilesGenerator/compare/v0.4.0...HEAD",
+        "[Unreleased]: https://github.com/donislawdev/TestingFilesGenerator/compare/v0.3.0...HEAD",
+        "TestEveryChangelogVersionHasItsLinkDefinition",
+    ),
+    (
+        # The plainest way around every import based guard here: curl needs no
+        # networking package of its own.
+        "the command line binary gains the ability to start a program",
+        "cmd/tfg/main.go",
+        '\t"context"\n\t"os"',
+        '\t"context"\n\t_ "os/exec"\n\t"os"',
+        "TestTheCommandLineBinaryCannotStartAProcess",
+    ),
+    (
+        # The library load is wanted and documented. Swapping which library is
+        # the whole of what a telemetry addition would need to change.
+        "the dark menu call loads a network library instead of the theme one",
+        "internal/gui/darkmenus_windows.go",
+        'systemLibraryPath("uxtheme.dll")',
+        'systemLibraryPath("wininet.dll")',
+        "TestNothingShippedHoldsAnUndeclaredWayOut",
+    ),
+    (
+        # An address INSIDE a longer literal, which is the shape the first
+        # version of this guard read straight past.
+        "an endpoint replaces a namespace inside a longer string",
+        "internal/format/svgfile/svg.go",
+        "http://www.w3.org/2000/svg",
+        "https://telemetry.example/v1",
+        "TestNothingShippedHoldsAnUndeclaredWayOut",
+    ),
+    (
+        # The other direction: a permission whose code is gone. This is how an
+        # allowlist rots into a rubber stamp.
+        "a registered address leaves the code and the permission stays behind",
+        "internal/legal/modules.go",
+        "https://github.com/nicksnyder",
+        "https://example.invalid/moved",
+        "TestEveryTelemetryExceptionStillNamesLiveCode",
+    ),
+    (
+        "the scanner stops seeing a socket call",
+        "internal/guard/notelemetry_test.go",
+        "if socketCalls[name] {",
+        "if socketCalls[name] && false {",
+        "TestTheTelemetryScannerRejectsCodeItMustReject",
+    ),
+    (
+        "the scanner stops seeing a spawned process",
+        "internal/guard/notelemetry_test.go",
+        "if spawnCalls[name] {",
+        "if spawnCalls[name] && false {",
+        "TestTheTelemetryScannerRejectsCodeItMustReject",
+    ),
+    (
+        # The bug this file actually had, put back. Reading only position zero
+        # is exactly "the literal must START with the scheme", which shipped
+        # past a thirteen case canary because every case put the URL on its own.
+        "the address scan goes back to reading only the start of a literal",
+        "internal/guard/notelemetry_test.go",
+        "for i := 0; i < len(text); i++ {",
+        "for i := 0; i < 1; i++ {",
+        "TestTheTelemetryScannerRejectsCodeItMustReject",
+    ),
+    (
+        # A scanner that refuses everything satisfies the canary and guards
+        # nothing, so the negative half needs its own break.
+        "the address scan treats every position in every string as an address",
+        "internal/guard/notelemetry_test.go",
+        "if !strings.HasPrefix(rest, scheme) {",
+        "if !strings.HasPrefix(rest, scheme) && false {",
+        "TestTheTelemetryScannerLeavesOrdinaryCodeAlone",
+    ),
+    (
+        # The registry is per file on purpose. An address permitted everywhere
+        # once it is permitted anywhere would let the next one in beside it.
+        "the registry stops caring which file an address was registered for",
+        "internal/guard/notelemetry_test.go",
+        'if _, ok := allowedURLs[rel+"|"+url]; ok {',
+        "if _, ok := allowedURLs[url]; ok {",
+        "TestARegisteredAddressInItsOwnFileIsAllowed",
+    ),
+    (
+        # A platform the release ships and this does not is a fix somebody
+        # cannot have, and it looks exactly like a build that did not run.
+        "the build on demand quietly stops offering a command line platform",
+        ".github/workflows/dev-build.yml",
+        "            windows/amd64 windows/arm64 \\",
+        "            windows/amd64 \\",
+        "TestTheBuildOnDemandOffersEveryPlatformTheReleaseDoes",
+    ),
+    (
+        "the build on demand builds the window for a different system than the release",
+        ".github/workflows/dev-build.yml",
+        "          - macos-latest",
+        "          - macos-14",
+        "TestTheBuildOnDemandOffersEveryPlatformTheReleaseDoes",
+    ),
+    (
+        # One careless line is all it takes for an unsigned binary to reach a
+        # release page that a person is supposed to sign for.
+        "the build on demand gains permission to write",
+        ".github/workflows/dev-build.yml",
+        "  contents: read",
+        "  contents: write",
+        "TestTheBuildOnDemandCannotPublishAndSaysItIsUnofficial",
+    ),
+    (
+        # The window archives lose the note while the command line ones keep
+        # it, which is the shape a single found-it check would pass.
+        "one of the two jobs stops writing the note that says this is not a release",
+        ".github/workflows/dev-build.yml",
+        '            .github/scripts/unofficial_note.sh "${work}/UNOFFICIAL-BUILD.txt" "${short}"',
+        "            true",
+        "TestTheBuildOnDemandCannotPublishAndSaysItIsUnofficial",
+    ),
+    (
+        # The note stops saying the one thing that explains why the reader's
+        # own system is refusing to open the file.
+        "the note stops saying the binaries are unsigned",
+        ".github/scripts/unofficial_note.sh",
+        "It is NOT signed.",
+        "It is ready to use.",
+        "TestTheUnofficialNoteSaysWhatTheArchiveIsNot",
+    ),
+    (
+        "a window binary with no window in it exits zero, so a script is told the window opened",
+        "internal/gui/run_nocgo.go",
+        "\treturn 1",
+        "\treturn 0",
+        "TestABuildWithNoWindowInItSaysSoAndKeepsStandardOutputEmpty",
+    ),
+    (
+        "a window binary with no window in it stops saying why, leaving an exit code and nothing to read",
+        "internal/gui/run_nocgo.go",
+        "fmt.Fprintln(errOut, text.NoWindowInThisBuild)",
+        "fmt.Fprintln(io.Discard, text.NoWindowInThisBuild)",
+        "TestABuildWithNoWindowInItSaysSoAndKeepsStandardOutputEmpty",
+    ),
+    (
+        "the window entry point stops handing back what opening the window answered, so a build with no window in it reports success",
+        "internal/gui/gui.go",
+        "\treturn run(ReadLaunch(args), errOut)",
+        "\treturn 0",
+        "TestABuildWithNoWindowInItSaysSoAndKeepsStandardOutputEmpty",
+    ),
+    (
+        # Verified by hand on 2026-09-05 before this entry was written: with the
+        # reversal in place the guard names files_0119.txt as offered out of
+        # place, and every other cleanup guard stays green - they ask what was
+        # removed, not in what order it was offered.
+        "the parallel pass answers in completion order rather than in the order the manifest lists",
+        "internal/audit/parallel.go",
+        "out[i] = one(i, scratch)",
+        "out[len(out)-1-i] = one(i, scratch)",
+        "TestCleanupOffersTheFilesInTheOrderTheManifestListsThem",
+    ),
+    (
+        # The workflow claimed the two lists could not drift because the map
+        # guard reddens first. It does - but only while the file is missing
+        # from the map, and adding it there is what that guard tells you to do.
+        # So this is the drift the claim did not cover.
+        "a file that declares concurrency is left off the list the race detector is run for",
+        ".github/workflows/ci.yml",
+        " internal/engine/parallel.go internal/tool/tool.go ",
+        " internal/tool/tool.go ",
+        "TestTheRaceDetectorIsRunForEveryFileThatDeclaresConcurrency",
+    ),
+    (
+        # Verified by hand on 2026-09-05 before this entry was written: with the
+        # old stat question back, the guard reports that the run went ahead over
+        # a name somebody else's link was holding - which is also the proof that
+        # the old behaviour really did replace it.
+        "a name held by a link pointing nowhere stops being a name that is taken",
+        "internal/engine/preflight.go",
+        "\tif taken == nil {\n\t\treturn exists(path)\n\t}\n\t_, ok := taken[name]\n\treturn ok",
+        "\treturn exists(path)",
+        "TestANameTakenByALinkPointingNowhereIsStillTaken",
+    ),
+    (
+        # A directory that refuses a LISTING needs an ACL on Windows, so the
+        # guard skips there and runs on the other two systems in the matrix.
+        # The mutation has to be tried on one of those.
+        "a directory that cannot be listed is read as an empty one, so a run writes over what is inside",
+        "internal/engine/preflight.go",
+        "\tentries, err := os.ReadDir(dir)\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\treturn map[string]struct{}{}\n\t}\n\tif err != nil {\n\t\treturn nil\n\t}",
+        "\tentries, err := os.ReadDir(dir)\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\treturn map[string]struct{}{}\n\t}\n\tif err != nil {\n\t\treturn map[string]struct{}{}\n\t}",
+        "TestADirectoryThatCannotBeListedIsStillAskedAboutEveryName",
+    ),
+    (
+        # The metric the performance report asked for. Measured 2026-09-05: with
+        # it in place all four of the older plan ceiling guards stay green, and
+        # only this one notices - which is why it was written.
+        "the plan ceiling asks about the last collection rather than about what has been allocated",
+        "internal/engine/planmemory.go",
+        "\"/gc/heap/allocs:bytes\"",
+        "\"/gc/heap/live:bytes\"",
+        "TestThePlanCeilingAsksAboutAllocationRatherThanTheLastCollection",
+    ),
+    (
+        "a format goes back to keeping its own copy of the bulk filler loop",
+        "internal/format/png/png.go",
+        "\t\tchunk := buf[:size]\n\t\tcore.FillRandomBE(chunk, rng)\n",
+        "\t\tchunk := buf[:size]\n\t\tfor i := range chunk {\n\t\t\tchunk[i] = byte(rng.Uint64())\n\t\t}\n",
+        "TestBulkRandomBytesComeFromOnePlace",
+    ),
+    (
+        "the standing exception letting one format draw a single value outlives the code it was granted for",
+        "internal/format/opc/opc.go",
+        "rng.UintN(uint(len(Words)))",
+        "rng.IntN(len(Words))",
+        "TestBulkRandomBytesComeFromOnePlace",
+    ),
+    (
+        "the shared filler stops writing the last group shorter than eight bytes, so every odd sized file loses its tail",
+        "internal/core/fill.go",
+        "\tif i < len(b) {\n\t\tvar eight [8]byte\n\t\tbinary.BigEndian.PutUint64(eight[:], rng.Uint64())",
+        "\tif false && i < len(b) {\n\t\tvar eight [8]byte\n\t\tbinary.BigEndian.PutUint64(eight[:], rng.Uint64())",
+        "TestOurOwnGeneratorsHaveNotDrifted",
+    ),
+    (
+        "the cycle the steady tone is worked out over is off by one, so the pitch drifts a frame every time it wraps",
+        "internal/format/wav/signal.go",
+        "\tperiod := int64(rate) / gcd(int64(base), int64(rate))\n",
+        "\tperiod := int64(rate)/gcd(int64(base), int64(rate)) + 1\n",
+        "TestOurOwnGeneratorsHaveNotDrifted",
+    ),
+    (
+        "planning goes back to coding the picture it throws away, so a preview of a large run costs what the run costs",
+        "internal/format/png/png.go",
+        "\tif r.Bytes >= ladderCeiling+iendSize+chunkOverhead &&\n",
+        "\tif false && r.Bytes >= ladderCeiling+iendSize+chunkOverhead &&\n",
+        "TestPlanningAPictureDoesNotCodeIt",
+    ),
+    (
+        "the same for the animated format, where planning also stopped coding the picture",
+        "internal/format/gif/gif.go",
+        "\tif r.Bytes >= ladderCeiling+trailerSize+smallestCarryingComment {\n",
+        "\tif false && r.Bytes >= ladderCeiling+trailerSize+smallestCarryingComment {\n",
+        "TestPlanningAPictureDoesNotCodeIt",
+    ),
+    (
+        "the ceiling planning trusts drops below what the top rung really codes to, so planning accepts sizes the writer cannot deliver",
+        "internal/format/png/png.go",
+        "const ladderCeiling = 16384\n",
+        "const ladderCeiling = 4096\n",
+        "TestTheLadderCeilingIsAboveEveryPictureTheTopRungMakes",
+    ),
+    (
+        "the same ceiling drops for the animated format, where sixty frames code to far more than one",
+        "internal/format/gif/gif.go",
+        "const ladderCeiling = 98304\n",
+        "const ladderCeiling = 32768\n",
+        "TestTheLadderCeilingIsAboveEveryPictureTheTopRungMakes",
+    ),
+    (
+        "the directory chain loses the padding that keeps a level two digits wide, so every entry inside a deep archive is named something else",
+        "internal/format/archive/layout.go",
+        "\t\tif i < 10 {\n\t\t\tb.WriteByte('0')\n\t\t}\n",
+        "\t\tif false && i < 10 {\n\t\t\tb.WriteByte('0')\n\t\t}\n",
+        "TestTheDeepestPathEveryFormatCanMakeStillFitsAUstarHeader",
+    ),
+    (
+        "the reused buffer of a locked entry is handed on at its full length instead of the length written, so the archive runs past its size",
+        "internal/format/archive/lock.go",
+        "\tout := e.buf[:len(p)]\n",
+        "\tout := e.buf\n",
+        "TestALockedArchiveStillHitsTheSizeToTheByte",
+    ),
+    (
+        "the same for the older scheme, where the reused buffer is also handed on whole",
+        "internal/format/archive/zipcrypto.go",
+        "\tout := z.buf[:len(p)]\n",
+        "\tout := z.buf\n",
+        "TestALockedArchiveStillHitsTheSizeToTheByte",
+    ),
+    (
+        "the document the recipe is decoded from stops skipping one that holds nothing but a comment, so an ordinary leading separator is refused again",
+        "internal/recipe/recipe.go",
+        "\t\tif d.Body == nil || d.Body.Type() == ast.CommentType {\n\t\t\tcontinue\n\t\t}\n\t\treturn d.Body\n",
+        "\t\tif d.Body == nil {\n\t\t\tcontinue\n\t\t}\n\t\treturn d.Body\n",
+        "TestOneRecipeIsAcceptedWhateverSeparatorsSurroundIt",
+    ),
+    (
+        # Half of all sizes are unreachable in a two byte encoding, and the
+        # promise is that the tool refuses rather than rounds. Let every size
+        # through and a UTF-16 file comes out an odd number of bytes long -
+        # which three readers reject and three others repair in silence.
+        "a wide encoding accepts an odd size instead of refusing it",
+        "internal/format/textenc/textenc.go",
+        "if (fileBytes-int64(len(c.Preamble())))%c.width() == 0 {",
+        "if (fileBytes-int64(len(c.Preamble())))%c.width() == 0 || true {",
+        "TestAWideEncodingRefusesAnOddSizeAndNamesOneItCanWrite",
+    ),
+    (
+        # The content budget is file bytes divided by the width of a character.
+        # Divide by one and the file comes out twice as long as it was ordered.
+        "the content budget forgets that a character costs two bytes",
+        "internal/format/textenc/textenc.go",
+        "return (fileBytes - int64(len(c.Preamble()))) / c.width()",
+        "return fileBytes - int64(len(c.Preamble()))",
+        "TestATextFileIsTheEncodingItDeclares",
+    ),
+    (
+        # A mark that says the wrong byte order is worse than no mark: a reader
+        # believes it and decodes every character backwards.
+        "the little endian mark is written the other way round",
+        "internal/format/textenc/textenc.go",
+        "return []byte{0xFF, 0xFE}",
+        "return []byte{0xFE, 0xFF}",
+        "TestATextFileIsTheEncodingItDeclares",
+    ),
+    (
+        # Swap the order the units go out in and the file still DECODES - every
+        # pair of bytes is a valid character, 0x74 0x00 reads as U+7400 - so
+        # only a check that looks at what the characters ARE catches this.
+        "a big endian file is written little endian, and still decodes",
+        "internal/format/textenc/textenc.go",
+        "return append(dst, byte(u>>8), byte(u))",
+        "return append(dst, byte(u), byte(u>>8))",
+        "TestATextFileIsTheEncodingItDeclares",
+    ),
+    (
+        # The encoder holds the tail of a character split across two writes.
+        # Our own generators never split one, so this is the defence that only
+        # a guard written for it can redden.
+        "a character split across two writes loses its tail",
+        "internal/format/textenc/textenc.go",
+        "w.part = append(w.part[:0], p...)",
+        "w.part = w.part[:0]",
+        "TestACharacterSplitAcrossTwoWritesSurvives",
+    ),
+    (
+        # The note has to say what the label COSTS in this encoding. Report the
+        # length of the string instead and it is out by a factor of two.
+        "the note about a missing label reports its cost in the wrong encoding",
+        "internal/format/txt/txt.go",
+        "codec.Cost(int64(len(line))), r.Bytes),",
+        "int64(len(line)), r.Bytes),",
+        "TestALabelThatWillNotFitSaysWhatItWouldCost",
+    ),
+    (
+        # Whether the label fits is a question about characters, not about file
+        # bytes. Ask it in file bytes and a UTF-16 file writes a label twice
+        # the size of the room it has.
+        "the label is measured against the file size rather than what fits in it",
+        "internal/format/md/md.go",
+        "if int64(len(line)) <= m.source {",
+        "if int64(len(line)) <= r.Bytes {",
+        "TestALabelThatWillNotFitSaysWhatItWouldCost",
+    ),
+    (
+        # Saying nothing and saying utf-8 out loud have to be the same file. Give
+        # the default a mark and every hash in the golden file moves, which is a
+        # breaking change dressed as a new setting.
+        "the default encoding starts writing a byte order mark",
+        "internal/format/textenc/textenc.go",
+        "func Default() Codec { return Codec{name: UTF8} }",
+        "func Default() Codec { return Codec{name: UTF8, bom: true} }",
+        "TestTheDefaultEncodingIsTheBytesTheseFormatsAlwaysWrote",
+    ),
+    (
+        # The content budget is file bytes divided by the width of a character,
+        # which is only the same as "how much prose fits" while the vocabulary
+        # is ASCII. One accented word is the day that stops being true.
+        "the filler vocabulary stops being ASCII, and the width arithmetic stops holding",
+        "internal/format/txt/txt.go",
+        '"account", "after", "again", "amount", "answer", "before", "between",',
+        '"acc\u00f3unt", "after", "again", "amount", "answer", "before", "between",',
+        "TestTheTextGeneratorsWriteOnlyASCII",
+    ),
+    (
+        # Look the value up and then ignore what came back, keeping every
+        # symbol in use so the tree still builds. Every layout comes out as
+        # one record per line, at the right size, parsing everywhere - the
+        # file is simply not the one that was ordered.
+        "the json layout is read and then ignored",
+        "internal/format/jsonfile/style.go",
+        "\ts, known := styles[v]",
+        "\ts, known := styles[RecordPerLine]",
+        "TestAJSONDocumentIsLaidOutTheWayItWasOrdered",
+    ),
+    (
+        # A minified document that ends in a newline is not minified. It still
+        # parses, it is still the right size, and the one thing somebody asked
+        # for is gone.
+        "a minified document ends with a newline after all",
+        "internal/format/jsonfile/style.go",
+        "\ttailLast: `\"}]`,",
+        "\ttailLast: \"\\\"}]\\n\",",
+        "TestAJSONDocumentIsLaidOutTheWayItWasOrdered",
+    ),
+    (
+        # The floor is worked out for the DEFAULT layout rather than the one
+        # asked for, so an indented document is offered a size it cannot hold.
+        "the json minimum is worked out for the wrong layout",
+        "internal/format/jsonfile/json.go",
+        "\tr := records{s: s}",
+        "\tr := records{s: defaultStyle()}",
+        "TestEachJSONLayoutAnswersForItsOwnMinimum",
+    ),
+    (
+        # Saying nothing has to be the layout this format always wrote. Move
+        # the default and every hash anybody recorded moves with it.
+        "the default json layout stops being the one it always was",
+        "internal/format/jsonfile/style.go",
+        "func defaultStyle() style { return recordPerLine }",
+        "func defaultStyle() style { return minified }",
+        "TestTheDefaultLayoutIsTheBytesJSONAlwaysWrote",
+    ),
+    (
+        # The checker stops caring how many lines a record per line document
+        # has, so it blesses a minified file told it is one. Every case that
+        # hands it the RIGHT layout still passes - only the canary handing it
+        # the wrong one can see this.
+        "the json checker stops counting lines, and blesses a layout the file is not",
+        "internal/oracle/strict.py",
+        "        if newlines != len(doc) + 2:",
+        "        if newlines != len(doc) + 2 and False:",
+        "TestTheJSONCheckerRefusesALayoutTheFileIsNot",
+    ),
+    (
+        # The clamp that keeps an argument to IntN positive stops clamping.
+        # At 800 by 600 nothing changes, because every argument is already
+        # positive - which is exactly why this needed a guard: the defect is
+        # invisible at the only size this format could produce until now.
+        # Measured 2026-09-08 by running it: IntN(0) and IntN(-30) panic with
+        # "invalid argument to IntN".
+        "an SVG canvas smaller than the margins its shapes assume",
+        "internal/format/svgfile/svg.go",
+        "\tif n < 1 {",
+        "\tif n < 1 && false {",
+        "TestASvgCanvasSmallerThanItsOwnMarginsStillDraws",
+    ),
+    (
+        # The clamp that keeps a shape's lower edge out of the strip the label
+        # sits in stops clamping. Inert at 800 by 600 - the widest shape lands
+        # two units short of the strip - so only a small canvas can see it.
+        # This is the defect the strip was added for in the first place: about
+        # one shape in ten landed on the label, 11.0% and 10.3% measured.
+        "a shape reaches past the drawing into the label strip",
+        "internal/format/svgfile/svg.go",
+        "func fit(extent, room int) int { return min(extent, room) }",
+        "func fit(extent, room int) int { _ = room; return extent }",
+        "TestNoSvgShapeReachesIntoTheStripTheLabelSitsIn",
+    ),
+    (
+        # The root element goes back to announcing 800 by 600 whatever was
+        # ordered. The file is still well formed, still the exact size asked
+        # for, and still opens - it simply lies about how big the drawing is,
+        # which is the whole of what this setting does.
+        "the root element reports the default size whatever was asked for",
+        "internal/format/svgfile/svg.go",
+        "func rootOpen(w, h int) string {",
+        "func rootOpen(w, h int) string {\n\tw, h = defaultWidth, defaultHeight",
+        "TestASvgCanvasSmallerThanItsOwnMarginsStillDraws",
+    ),
+    (
+        # A drawing too short to show the label claims to carry one anyway.
+        # Nothing about the bytes changes shape, the size stays exact, and the
+        # manifest quietly says label_embedded on a file where the label is
+        # painted off the canvas. Rule 6, in the form that is hardest to see.
+        "a drawing too short for the label claims to carry one",
+        "internal/format/svgfile/svg.go",
+        "func labelFits(h int) bool { return h > TextBand }",
+        "func labelFits(h int) bool { return h > TextBand || true }",
+        "TestASvgTooShortForTheLabelSaysSo",
+    ),
+    (
+        # Leaving a dimension out stops meaning the same as writing its
+        # declared default. Two journeys through the parser, the registry and
+        # the generator, allowed to disagree by accident - and every recipe
+        # written before this setting existed takes the first one.
+        "silence about the SVG canvas stops meaning the declared default",
+        "internal/format/imagedim/imagedim.go",
+        "\t\treturn fallback, nil",
+        "\t\treturn fallback + 1, nil",
+        "TestSayingTheSvgCanvasOutLoudChangesNoByte",
+    ),
+    # --- XML carries an encoding, and its declaration has to agree with its
+    #     bytes (2026-09-08, docs/XML-ENCODING-2026-09-08.md) ---
+    (
+        "the XML declaration goes back to naming one encoding whatever the bytes are",
+        "internal/format/xmlfile/xml.go",
+        "\treturn declarationUTF16",
+        "\treturn declarationUTF8",
+        "TestTheXMLDeclarationNamesTheEncodingTheBytesAreIn",
+    ),
+    (
+        "XML stops requiring a byte order mark in a wide encoding",
+        "internal/format/xmlfile/xml.go",
+        "\tif c.Name() == textenc.UTF8 || c.HasBOM() {",
+        "\tif true {",
+        "TestXMLRefusesAWideEncodingWithoutAMark",
+    ),
+    (
+        "the smallest XML file stops depending on the encoding it is written in",
+        "internal/format/xmlfile/xml.go",
+        "\treturn c.Cost(source) + int64(len(c.Preamble()))",
+        "\treturn source",
+        "TestATextFileIsTheEncodingItDeclares",
+    ),
+    (
+        "the byte order mark is planned for XML and never written",
+        "internal/format/xmlfile/xml.go",
+        "core.WriteAll(w, m.codec.Preamble())",
+        "core.WriteAll(w, nil)",
+        "TestATextFileIsTheEncodingItDeclares",
+    ),
+    (
+        "XML fills its document in file bytes rather than in characters",
+        "internal/format/xmlfile/xml.go",
+        "m.source-int64(len(head))",
+        "p.Bytes-int64(len(head))",
+        "TestATextFileIsTheEncodingItDeclares",
+    ),
+    (
+        "the XML label note reports what the comment costs in UTF-8",
+        "internal/format/xmlfile/xml.go",
+        "\t\t\t\t\tcodec.Cost(int64(len(line)))),",
+        "\t\t\t\t\tint64(len(line))),",
+        "TestALabelThatWillNotFitSaysWhatItWouldCost",
+    ),
+    (
+        "the structural check stops comparing the declaration with the bytes",
+        "internal/oracle/strict.py",
+        "    if opening.group(1) != want:",
+        "    if opening.group(1) != want and False:",
+        "TestTheStructuralCheckRefusesADeclarationThatDisagreesWithTheBytes",
+    ),
+    # --- a drawn size is snapped to one the format can write, and the move is
+    #     reported (2026-09-08, O190) ---
+    (
+        "a drawn size goes back to being used whether the format can write it or not",
+        "internal/engine/drawsizes.go",
+        "\t\tt.Sizes[i] = got",
+        "\t\tt.Sizes[i] = want",
+        "TestASizeTheFormatCannotWriteIsMovedInsideTheRange",
+    ),
+    (
+        "a size is moved to fit the format and nothing records that it was",
+        "internal/engine/drawsizes.go",
+        "\t\tt.SizeMoved[i] = got != want",
+        "\t\tt.SizeMoved[i] = false",
+        "TestAMovedSizeIsReported",
+    ),
+    (
+        "the note about a moved size is attached to every file of a range",
+        "internal/engine/plantarget.go",
+        "\t\tif idx < len(t.SizeMoved) && t.SizeMoved[idx] {",
+        "\t\tif idx < len(t.Sizes) {",
+        "TestAMovedSizeIsReported",
+    ),
+    (
+        "a range starting under what the format can do is no longer refused",
+        "internal/engine/drawsizes.go",
+        "\tif t.Range.Min < floor {",
+        "\tif t.Range.Min < floor && false {",
+        "TestARangeIsJudgedByItsLowEndRatherThanByWhatWasDrawn",
+    ),
+    (
+        "snapping walks past the top of the range instead of stopping at it",
+        "internal/engine/drawsizes.go",
+        "\tfor at := from; at <= limit; {",
+        "\tfor at := from; ; {",
+        "TestARangeHoldingNoWritableSizeIsRefusedBeforeAnyFile",
+    ),
+    (
+        "snapping starts from somewhere that depends on how many files were asked for",
+        "internal/engine/drawsizes.go",
+        "\t\tgot, err := firstWritable(desc, req, want, t.Range.Max)",
+        "\t\tgot, err := firstWritable(desc, req, want+int64(len(t.Sizes)), t.Range.Max)",
+        "TestSnappingStillLeavesTheEarlierFilesAlone",
+    ),
+    # --- HTML is a whole page or only the blocks that sit inside one
+    #     (2026-09-08, docs/HTML-STRUCTURE-2026-09-08.md) ---
+    # Repointed 2026-10-14, when html.go was split into settings.go, page.go,
+    # blocks.go, catalogue.go and characters.go - the defects are the same.
+    (
+        "a fragment gets the skeleton of a whole page in front of it",
+        "internal/format/htmlfile/page.go",
+        '\tif s.shape == structureDocument {\n\t\tp = m[headOpen]',
+        '\tif true {\n\t\tp = m[headOpen]',
+        "TestAFragmentCarriesNoSkeletonAndAPageCarriesOne",
+    ),
+    (
+        "a fragment still closes a body and a document it does not have",
+        "internal/format/htmlfile/content.go",
+        "\tif s.shape != structureDocument {\n\t\treturn \"\"\n",
+        "\tif false {\n\t\treturn \"\"\n",
+        "TestAFragmentCarriesNoSkeletonAndAPageCarriesOne",
+    ),
+    (
+        "every shape of HTML inherits the floor of a whole page",
+        "internal/format/htmlfile/html.go",
+        "\treturn int64(len(s.prologue())) + c.openingBytes()",
+        "\treturn int64(len(s.spelling()[headOpen]+s.spelling()[headClose])) + c.openingBytes()",
+        "TestEachHTMLShapeAnswersForItsOwnMinimum",
+    ),
+    (
+        "a fragment is given a title, which belongs to a head it has not got",
+        "internal/format/htmlfile/html.go",
+        "\tplain := s.prologue()",
+        "\tplain := headOpen + headClose",
+        "TestTheLabelIsVisibleInBothHTMLShapes",
+    ),
+    (
+        "an HTML recipe that says nothing gets a fragment instead of a page",
+        "internal/format/htmlfile/settings.go",
+        "choice(props, settingStructure, structureDocument, structureFragment)",
+        "choice(props, settingStructure, structureFragment, structureDocument)",
+        "TestTheDefaultHTMLShapeIsTheBytesItAlwaysWrote",
+    ),
+    (
+        "the structural check stops being told which HTML shape to expect",
+        "internal/oracle/strict.py",
+        '    shape = settings.get("structure", "document")',
+        '    shape = "document"',
+        "TestTheStructuralCheckIsToldWhichHTMLShapeToExpect",
+    ),
+    # --- HTML nesting, characters, elements and bom (2026-10-14,
+    #     docs/HTML-2-2026-10-14.md) ---
+    (
+        "the floor of an html page carries a byte of text nobody needed",
+        "internal/format/htmlfile/html.go",
+        "\treturn int64(len(s.prologue())) + c.openingBytes() + c.linksBytes() + c.shortestRecord() + c.closingBytes()",
+        "\treturn int64(len(s.prologue())) + c.openingBytes() + c.linksBytes() + c.shortestRecord() + c.closingBytes() + 1",
+        "TestEveryHTMLSettingAnswersForItsOwnMinimum",
+    ),
+    (
+        "the floor of an html page forgets the levels it has to close",
+        "internal/format/htmlfile/html.go",
+        "\treturn int64(len(s.prologue())) + c.openingBytes() + c.linksBytes() + c.shortestRecord() + c.closingBytes()",
+        "\treturn int64(len(s.prologue())) + c.openingBytes() + c.linksBytes() + c.shortestRecord() + int64(len(c.documentClose()))",
+        "TestEveryHTMLSettingAnswersForItsOwnMinimum",
+    ),
+    (
+        "html nesting opens half the levels it was asked for",
+        "internal/format/htmlfile/content.go",
+        "\tfor k := 0; k < s.nesting && out.err == nil; k++ {",
+        "\tfor k := 0; k < s.nesting/2 && out.err == nil; k++ {",
+        "TestNestingPutsTheContentThatManyLevelsDeep",
+    ),
+    (
+        "the html label moves inside the levels, where a reader drops it",
+        "internal/format/htmlfile/html.go",
+        "\tif err := core.WriteAll(w, []byte(head)); err != nil {\n\t\treturn err\n\t}\n\tif err := c.writeOpening(ctx, w, in); err != nil {\n\t\treturn err\n\t}\n",
+        "\tif err := c.writeOpening(ctx, w, in); err != nil {\n\t\treturn err\n\t}\n\tif err := core.WriteAll(w, []byte(head)); err != nil {\n\t\treturn err\n\t}\n",
+        "TestNestingPutsTheContentThatManyLevelsDeep",
+    ),
+    (
+        "the manifest records no html nesting whatever was asked",
+        "internal/format/htmlfile/settings.go",
+        "\t\tsettingNesting:     s.nesting,",
+        "\t\tsettingNesting:     0,",
+        "TestEveryHTMLSettingIsRecordedInTheManifest",
+    ),
+    (
+        "an html recipe that says nothing gets a level of nesting",
+        "internal/format/htmlfile/settings.go",
+        "\tif !ok || v == \"\" {\n\t\treturn 0, nil",
+        "\tif !ok || v == \"\" {\n\t\treturn 1, nil",
+        "TestTheDefaultHTMLSettingsAreTheBytesItAlwaysWrote",
+    ),
+    (
+        "the structural check stops counting the html levels",
+        "internal/oracle/strict.py",
+        "    if opened != want or closed != want:",
+        "    if False:",
+        "TestTheStructuralCheckIsToldEveryHTMLSetting",
+    ),
+    (
+        "the catalogue of every element loses search",
+        "internal/format/htmlfile/catalogue.go",
+        "
", + "
", + "TestEveryElementOfTheStandardIsInTheCatalogue", + ), + ( + "a fragment of every element leaves eight out without a word", + "internal/format/htmlfile/html.go", + "\tif s.every && s.shape == structureFragment {", + "\tif false {", + "TestEveryElementOfTheStandardIsInTheCatalogue", + ), + ( + "the catalogue of every element fetches its picture from a network", + "internal/format/htmlfile/catalogue.go", + "\tsmallPicture = \"data:image/png;base64,", + "\tsmallPicture = \"https://example.com/p.png#", + "TestEveryElementOfTheStandardIsInTheCatalogue", + ), + ( + "the arabic paragraph loses its direction", + "internal/format/htmlfile/characters.go", + "`

`", + "`

`", + "TestInternationalCarriesEveryScriptAndASCIICarriesNone", + ), + ( + "an ascii html page carries the paragraphs in other scripts", + "internal/format/htmlfile/settings.go", + "\t\ts.international = v == charactersInternational", + "\t\ts.international = v != \"\"", + "TestInternationalCarriesEveryScriptAndASCIICarriesNone", + ), + ( + "the html byte order mark lands where it was not ordered", + "internal/format/htmlfile/page.go", + "\tif s.bom {", + "\tif !s.bom {", + "TestTheHTMLByteOrderMarkIsThereOnlyWhenOrdered", + ), + ( + "the command line names a damage setting and stops saying what it may be", + "internal/cli/damagecmd.go", + "\t\tfmt.Fprintf(out, \" %-14s %s\\n\", p.Name, p.Allowed())", + "\t\tfmt.Fprintf(out, \" %-14s %s\\n\", p.Name, \"\")", + "TestTheCommandLineSaysWhatEveryDamageTakes", + ), + ( + "the smallest file a damage announces stops following its declaration", + "internal/cli/damagecmd.go", + "\treturn d.Floor(d.Defaults())", + "\treturn 1", + "TestTheSmallestFileTheDamageCommandAnnouncesIsOneARunAccepts", + ), + ( + "the machine readable damage list keeps the names and drops the settings", + "internal/cli/damagecmd.go", + "\t\tSmallestFileBytes: defaultFloor(d), Parameters: params,", + "\t\tSmallestFileBytes: defaultFloor(d), Parameters: params[:0],", + "TestTheMachineReadableDamageListCarriesTheWholeDeclaration", + ), + ( + "a command joins the dispatch and nobody adds it to the list watching --help", + "internal/cli/commands.go", + "\t\t\tVerb: \"formats\", Summary:", + "\t\t\tVerb: \"listing\", Summary:", + "TestEveryCommandTakingItsOwnArgumentsIsWatchedForHelp", + ), + ( + "the format list answers about the first of two names instead of refusing", + "internal/cli/formats.go", + "\twanted, ok := atMostOneName(leading, fs, errOut)\n\tif !ok {\n\t\treturn ExitUsage\n\t}", + "\twanted, ok := atMostOneName(leading, fs, errOut)\n\tif !ok && false {\n\t\treturn ExitUsage\n\t}", + "TestAListingCommandRefusesANameItCannotAnswerAbout", + ), + ( + "the preset list swallows a name it has no use for", + "internal/cli/presetcmd.go", + "\tif extra := fs.Args(); len(extra) > 0 {", + "\tif extra := fs.Args(); len(extra) > 0 && false {", + "TestAListingCommandRefusesANameItCannotAnswerAbout", + ), + ( + "a package below both surfaces writes a setting in command line spelling", + "internal/damage/refusals.go", + "\treturn \"remove the damage line\"", + "\treturn \"remove --damage from this target\"", + "TestNoMessageBelowTheSurfacesIsWrittenInFlagSpelling", + ), + # The two entries about the column of names (a name dropped from the + # hand written list, a screen never told the column's width) went with + # the column on 2026-09-22: a name stands over its box, and there is no + # list and no width to drop anything from. + ( + # The other half of the stacked form: the box indented under its name + # reads as a child of the name rather than as the value it holds. + "a field's box is indented under its name", + "internal/gui/parts/field.go", + "\tbody := FieldStack(pieces...)", + "\tbody := FieldStack(pieces[0], Padded(GapLabel, pieces[1]))", + "TestANameStandsOverItsBoxOnOneEdgeWithEveryOther", + ), + # --- the line at the foot of the form (2026-09-14, step 3 of the rework) --- + ( + # The count on the line stops being the count on the form. Off by one + # rather than gone, so a guard that only asked whether the line says + # SOMETHING would stay green. + "the line counts one file more than the form asks for", + "internal/gui/window/runline.go", + "\treturn text.RunLine(int(sum.files), sum.totalText(), sum.formats, l.where(dir))", + "\treturn text.RunLine(int(sum.files)+1, sum.totalText(), sum.formats, l.where(dir))", + "TestTheLineSaysWhatTheFormComesTo", + ), + ( + # A menu stops reporting its change, which is what every menu did + # until this step: the line goes on naming the format chosen when the + # screen was built. The box and the switch still report, so nothing + # typed would show it. + "a menu stops telling the runner it changed, so the line names the format from before", + "internal/gui/parts/fields.go", + "\t\tcase *Chooser:\n\t\t\tchainOnce(&it.OnChanged, &it.reports, setting, report)\n", + "\t\tcase *Chooser:\n\t\t\tchainOnce(&it.OnChanged, &it.reports, setting, func(string, string) {})\n", + "TestTheLineFollowsTheMenu", + ), + ( + # A form that does not settle keeps the line of the last one that + # did, so a count stands on the bar for a run that will be refused. + "the line keeps a stale summary when the form stops settling", + "internal/gui/window/run.go", + "\t\tshowOn(r.status, r.line.fallback(dir))\n\t\treturn\n", + "\t\t_ = dir\n\t\treturn\n", + "TestTheLineFallsBackToTheDestinationWhenTheFormDoesNotSettle", + ), + ( + # A range is added up as if its sizes were stated, so the line says + # one number about files whose sizes have not been drawn. + "a range is summed as though its sizes were already drawn", + "internal/gui/window/runsummary.go", + "\t\ts.lower(drawn)\n", + "\t\ts.lower(stated)\n", + "TestTheLineSaysBetweenForARange", + ), + ( + # The preview stops saying that nothing exists yet, so an exact line + # reads as a report of files that were written. + "the preview's line stops saying that nothing was written", + "internal/gui/window/run.go", + "\tsaid := r.line.said(exactly(planned), opt.OutDir) + text.AndNothingWrittenYet()", + "\tsaid := r.line.said(exactly(planned), opt.OutDir)", + "TestAPreviewMakesTheLineExact", + ), + ( + # The other half of the same guard: the disk is asked and the answer + # thrown away, so the destination never carries the room left. + "the preview measures the disk and tells the line nothing", + "internal/gui/window/run.go", + "\tif room.known {\n\t\tr.line.measured(opt.OutDir, room.free)\n\t}\n\tsaid :=", + "\tif room.known && false {\n\t\tr.line.measured(opt.OutDir, room.free)\n\t}\n\tsaid :=", + "TestAPreviewMakesTheLineExact", + ), + ( + # A change of the form stops putting the summary back, so what a + # press said about a form that no longer exists stays on the line - + # the one way flag this step removed, back again. + "a change of the form leaves what a press said standing", + "internal/gui/window/runrefuse.go", + "\tlineFrom(r, targets, opt, err)\n\t// Only this box, in both directions.", + "\t_, _ = targets, opt\n\t// Only this box, in both directions.", + "TestAChangeOfTheFormPutsTheLineBackOverWhatAPressSaid", + ), + ( + # Step 4 of the window rework, 2026-09-15. The strip goes through the + # same column and indent as a screen's title, so the two share an edge + # by construction - and this is the construction taken away: the strip + # on the column's edge, the title sixteen pixels in. + "the strip stands on the column's edge rather than on the words' edge", + "internal/gui/parts/tabs.go", + "container.New(readableWidth{}, Indented(strip))", + "container.New(readableWidth{}, strip)", + "TestTheStripStandsOnTheEdgeTheScreenReadsFrom", + ), + ( + # The one vocabulary rule the other way up: the old title back at the + # top and the tab's word under it as the explanation. + "a screen's title and the sentence under it swap places", + "internal/gui/window/generate.go", + "parts.Titled(text.TabOneTarget(), text.SubtitleGenerate())", + "parts.Titled(text.SubtitleGenerate(), text.TabOneTarget())", + "TestAScreenIsTitledWithTheWordOnItsTab", + ), + ( + # Somebody who chose a screen with Enter is using the keyboard, so the + # first field is marked for them. Placed quietly, the way a press + # places it, they would have no idea where the next key goes. + "a choice made from the keyboard places the keyboard quietly", + "internal/gui/window/open.go", + "\t\tfocusFirst(item.ID, byKeyboard)", + "\t\tfocusFirst(item.ID, false)", + "TestEnterOnAWordChoosesItsScreenAndHandsTheKeyboardOnVisibly", + ), + ( + # The toolkit's strip could not be worked from the keyboard at all, + # and this is that state put back for the key that matters most. + "Enter on a word of the strip chooses nothing", + "internal/gui/parts/tabs.go", + "\tcase fyne.KeyReturn, fyne.KeyEnter, fyne.KeySpace:\n\t\tw.strip.choose(w.index, true)", + "\tcase fyne.KeyReturn, fyne.KeyEnter, fyne.KeySpace:\n\t\t_ = w.index", + "TestEnterOnAWordChoosesItsScreenAndHandsTheKeyboardOnVisibly", + ), + ( + # Automatic activation, which the web also offers for a row of tabs + # and which does not fit a strip whose choice moves the keyboard away: + # every arrow would leave the strip. + "the right arrow chooses a screen instead of moving along the strip", + "internal/gui/parts/tabs.go", + "\tcase fyne.KeyRight:\n\t\tw.strip.focusWord(w.index + 1)", + "\tcase fyne.KeyRight:\n\t\tw.strip.choose(w.index+1, true)", + "TestTheArrowsMoveTheKeyboardAlongTheStripWithoutChoosing", + ), + ( + # The same defect the menu and the switch had, in the strip: a press + # takes the keyboard loudly, and on the chosen word nothing moves it + # on, so the mark stays. + "a press on a word of the strip draws the keyboard mark", + "internal/gui/parts/tabs.go", + "\t\tw.from.Quietly(func() { c.Focus(w) })", + "\t\tc.Focus(w)", + "TestAPressOnAWordChoosesWithoutDrawingTheKeyboardMark", + ), + ( + # O205 in the strip: the event arrives, the flag is set, and the + # picture does not change. Only a guard reading the drawn colour sees + # it. + "the pointer over a word of the strip changes nothing on the screen", + "internal/gui/parts/tabs.go", + "\tif r.word.chosen || r.word.hovered {\n\t\tr.text.Color", + "\tif r.word.chosen {\n\t\tr.text.Color", + "TestAWordUnderThePointerIsDrawnAtFullStrength", + ), + ( + # The strip names one screen and shows another. The toolkit's tabs + # did the showing inside their renderer, and ours does it in choose - + # so this is the half of choose that a guard reading the strip alone + # would never miss. + "the chosen screen is named and not shown", + "internal/gui/parts/tabs.go", + "\tt.current = i\n\tt.items[i].Content.Show()", + "\tt.current = i", + "TestEnterOnAWordChoosesItsScreenAndHandsTheKeyboardOnVisibly", + ), + ( + # Every screen shown at once under the first word. The strip is what + # hides the three not chosen, and the guard asks the tree rather than + # the strip which screens are visible. + "every screen is shown at once when the window opens", + "internal/gui/parts/tabs.go", + "\t\t} else {\n\t\t\ttab.Content.Hide()", + "\t\t} else {\n\t\t\ttab.Content.Show()", + "TestTheWindowOpensOnTheGenerateScreen", + ), + ( + # The window is set in Inter, and this is the theme quietly handing + # the regular weight back to the toolkit. Every screen would still + # draw, in Noto Sans, and nothing but a width would say so. + "the regular weight falls back to the toolkit's face", + "internal/gui/parts/typeface.go", + "\t}\n\treturn interRegular\n}", + "\t}\n\treturn o.Theme.Font(style)\n}", + "TestTheWindowIsSetInInter", + ), + ( + "the bold weight falls back to the toolkit's face", + "internal/gui/parts/typeface.go", + "\tcase style.Bold:\n\t\treturn interBold", + "\tcase style.Bold:\n\t\treturn o.Theme.Font(style)", + "TestTheWindowIsSetInInter", + ), + ( + # Inter ships here in two weights and no italic. An italic answered + # with the upright is a style silently drawn as another one. + "an italic is answered with the upright Inter", + "internal/gui/parts/typeface.go", + "\tcase style.Monospace || style.Italic || style.Symbol:", + "\tcase style.Monospace || style.Symbol:", + "TestTheStylesTheWindowNeverDrawsKeepTheToolkitsFace", + ), + ( + # The package that embeds the font stops saying so, and the running + # binary has no other way to learn it: both binaries are one module. + # Announced under the wrong name rather than not at all: dropping the + # call leaves the import unused and the tree does not compile, which + # proves nothing (trap 4 in CLAUDE.md). + "the font package announces itself under a name the registry does not know", + "internal/gui/font/carry.go", + "\tlegal.Carrying(packagePath)", + "\tlegal.Carrying(packagePath + \"/typeface\")", + "TestEveryPackageOfOursThatEmbedsSomebodyElsesBytesAnnouncesItself", + ), + ( + # The command line binary takes the window's font along. Nothing + # structural stops an import like this, and tfg license would start + # naming a font in a binary the notices say has none. + "the command line binary links the window's font", + "cmd/tfg/main.go", + '\t"github.com/donislawdev/TestingFilesGenerator/internal/cli"', + '\t"github.com/donislawdev/TestingFilesGenerator/internal/cli"\n\t_ "github.com/donislawdev/TestingFilesGenerator/internal/gui/font"', + "TestTheCommandLineLinksNoBytesOfSomebodyElsesThatWeEmbed", + ), + ( + # The registry misspells this project's module, so every entry of our + # own is answered as somebody else's and drops off every list. + "the registry spells this project's module wrong", + "internal/legal/legal.go", + 'const ourModule = "github.com/donislawdev/TestingFilesGenerator"', + 'const ourModule = "github.com/donislawdev/TestingFileGenerator"', + "TestEveryAssetOfOurOwnIsSpelledAsGoListSpellsIt", + ), + ( + # Half of the font ships with no entry accounting for it. Until + # 2026-09-15 this guard skipped our own module and would have passed. + "a font file of ours ships with nothing in the registry accounting for it", + "internal/legal/assets.go", + '"Inter-Regular.ttf", "Inter-Bold.ttf"', + '"Inter-Regular.ttf"', + "TestEveryFileEmbeddedFromSomebodyElseIsAccountedFor", + ), + ( + # A file of our own work vanishes from the list of our own work, and + # the guard has to say whose it is rather than assume. + "our own icon is on neither list", + "internal/guard/embeddedassets_test.go", + '\t"github.com/donislawdev/TestingFilesGenerator/internal/gui/icon chickpea.png": true,', + '\t// "github.com/donislawdev/TestingFilesGenerator/internal/gui/icon chickpea.png": true,', + "TestEveryFileEmbeddedFromSomebodyElseIsAccountedFor", + ), + ( + # An entry of ours is answered by module after all, and both binaries + # are one module - so the command line binary carries a font. + "an entry of ours is attached to every binary", + "internal/legal/carried.go", + "\t\treturn ours[asset.Package]\n\t}", + "\t\treturn true\n\t}", + "TestTheSBOMKeepsTheTwoBinariesApart", + ), + ( + # A part the catalogue stops vouching for: the star beside a name is + # drawn as a state of Fields, and the registry no longer says so. + # An exemption that outlived its file. The stale check below it in + # the guard asks the registry the same question; until 2026-09-16 + # nothing asked ownWork, so a key for a file that had gone would have + # exempted whatever landed at that path next - named by an outside + # review of #103. + "an ownWork exemption names a file no build embeds", + "internal/guard/embeddedassets_test.go", + "var ownWork = map[string]bool{\n", + "var ownWork = map[string]bool{\n\t\"github.com/donislawdev/TestingFilesGenerator/internal/gui/icon nothing.png\": true,\n", + "TestEveryFileEmbeddedFromSomebodyElseIsAccountedFor", + ), + ( + "a type of parts is on no list of the catalogue", + "internal/gui/catalogue/fields.go", + 'Covers: []string{"FieldSaying", "CellSaying", "FieldStack", "RequiredMark", "Table", "ToggleSaying"}', + 'Covers: []string{"FieldSaying", "CellSaying", "FieldStack", "Table", "ToggleSaying"}', + "TestTheCatalogueCoversEveryPartAndNothingElse", + ), + # --- a picture is read by its text chunks, and they are still read (2026-09-16) --- + ( + # The text chunk stops being read, so an address stamped into a + # picture leaves unread - the narrowing turned into "pictures are + # never read". + "a picture's text chunk is no longer read for private content", + "internal/guard/privatepng_test.go", + "\t\treturn []string{string(bytes.ReplaceAll(data, []byte{0}, []byte{' '}))}, nil", + '\t\treturn []string{""}, nil', + "TestAnAddressInAPictureTextChunkIsStillFound", + ), + # --- a picture is read whole or refused (2026-09-16, CodeRabbit on #105) --- + ( + # The read stops AT the room instead of one byte past it: a stream + # longer than the room comes back exactly the room long, fits the + # budget, and the rest leaves unread - the silent cut the review named. + "a text chunk longer than the room is cut there rather than refused", + "internal/guard/privatepng_test.go", + "\t\tb, err = io.ReadAll(io.LimitReader(r, int64(room)+1))", + "\t\tb, err = io.ReadAll(io.LimitReader(r, int64(room)))", + "TestAPictureThisGuardCannotReadWholeIsRefused", + ), + ( + # The room is never spent, so the budget is per chunk again: two + # chunks that each fit are read together past it. + "the text budget is per chunk rather than per picture", + "internal/guard/privatepng_test.go", + "\t\tp.room -= len(piece)", + "\t\tp.room -= 0", + "TestAPictureThisGuardCannotReadWholeIsRefused", + ), + ( + # A stream that will not inflate - a wrong checksum, say - is read to + # its end and handed over as if it were whole, which is what it did + # until 2026-09-16 with "" and is the same silence either way. + "compressed text that will not inflate is read as it stands rather than refused", + "internal/guard/privatepng_test.go", + '\t\treturn "", fmt.Errorf("has compressed text that will not inflate: %w", err)', + "\t\treturn string(b), nil", + "TestAPictureThisGuardCannotReadWholeIsRefused", + ), + ( + # A chunk running past the end of the file ends the reading quietly, + # the way it did until 2026-09-16. + "a chunk running past the end of the file ends the reading rather than refusing it", + "internal/guard/privatepng_test.go", + '\t\t\treturn "", fmt.Errorf("has a %s chunk of %d bytes that runs past the end of the file", kind, length)', + "\t\t\tbreak", + "TestAPictureThisGuardCannotReadWholeIsRefused", + ), + ( + # Whatever follows IEND is nobody's business again. + "bytes after IEND are ignored rather than refused", + "internal/guard/privatepng_test.go", + "\t\t\tif len(rest) > 0 {", + "\t\t\tif len(rest) > 0 && false {", + "TestAPictureThisGuardCannotReadWholeIsRefused", + ), + # --- a heading laid out to nothing is refused, a hidden one is not measured (2026-09-16) --- + ( + # The skip comes back: a heading the layout collapsed to nothing is + # stepped over, and the guard passes on the strength of the others. + "a heading laid out to nothing is stepped over rather than refused", + "internal/guard/oneline_test.go", + "\tif width = o.Size().Width; width == 0 {", + "\tif width = o.Size().Width; width == 0 && false {", + "TestAHeadingLaidOutToNothingIsRefusedAndAHiddenOneIsNotMeasured", + ), + ( + # Hiddenness stops counting: a heading nobody can see is refused for + # the room it never had, which would make every folded section red. + "a heading under something hidden is refused for the room it never had", + "internal/guard/oneline_test.go", + "\tif buried[o] {", + "\tif buried[o] && false {", + "TestAHeadingLaidOutToNothingIsRefusedAndAHiddenOneIsNotMeasured", + ), + # --- a title short of room ends in an ellipsis, and none of ours is (2026-09-16, O214) --- + ( + # The cut comes off: a long title runs past its panel again, the way + # canvas words did. + "a title short of room runs past its panel instead of ending in an ellipsis", + "internal/gui/parts/parts.go", + " label.Truncation = fyne.TextTruncateEllipsis", + " label.Truncation = fyne.TextTruncateOff", + "TestATitleTooLongForItsRoomEndsInAnEllipsisInsideIt", + ), + ( + # A title wraps onto a second line instead - a sentence's answer, + # which a name does not get. + "a title short of room wraps onto a second line", + "internal/gui/parts/parts.go", + " label.Wrapping = fyne.TextWrapOff", + " label.Wrapping = fyne.TextWrapWord", + "TestATitleTooLongForItsRoomEndsInAnEllipsisInsideIt", + ), + ( + # A word of ours grows past its room: every screen's title is drawn + # ten times over, so every one of them is cut on a shipped screen. + "a screen's title is longer than the panel it heads", + "internal/gui/parts/parts.go", + " return oneLine(text, theme.SizeNameSubHeadingText)", + " return oneLine(text+text+text+text+text+text+text+text+text+text, theme.SizeNameSubHeadingText)", + "TestEveryTitleAndBlockNameFitsWhereItStands", + ), + # --- a first start opens as tall as the screens want (2026-09-16, O202) --- + ( + # The want is ignored: a first start opens at the ceiling again, which + # is the number measured once in August and typed in. + "a first start ignores what the screens want and opens at the ceiling", + "internal/gui/window/opening.go", + " size.Height = fyne.Min(wanted.Height, LargestOpening.Height)", + " size.Height = LargestOpening.Height", + "TestAFirstStartOpensAsTallAsTheScreensWantInTheMiddle", + ), + ( + # The ceiling comes off: a want taller than somebody's screen opens + # taller than it, with its bottom out of reach. + "a first start opens taller than the ceiling when the screens want more", + "internal/gui/window/opening.go", + " size.Height = fyne.Min(wanted.Height, LargestOpening.Height)", + " size.Height = wanted.Height", + "TestAFirstStartOpensAsTallAsTheScreensWantInTheMiddle", + ), + ( + # The strip above the screens is left out of the want, so the first + # screen scrolls from the first frame by the height of the tabs. The + # call stays, multiplied by nought, so the import stays used - taking + # it out broke the build and proved nothing (2026-09-21). + "the first opening forgets the strip above the screens", + "internal/gui/window/opening.go", + "parts.AboveTheScreens(tabbed)+first.Unscrolled()+2*theme.Padding())", + "parts.AboveTheScreens(tabbed)*0+first.Unscrolled()+2*theme.Padding())", + "TestTheFirstOpeningShowsTheFirstScreenWholeAndNoMore", + ), + ( + # The canvas padding is forgotten, which is the eight pixels the first + # version of this was short by - measured, not imagined. + "the first opening forgets the padding the canvas keeps round the content", + "internal/gui/window/opening.go", + "parts.AboveTheScreens(tabbed)+first.Unscrolled()+2*theme.Padding())", + "parts.AboveTheScreens(tabbed)+first.Unscrolled()+theme.Padding()-theme.Padding())", + "TestTheFirstOpeningShowsTheFirstScreenWholeAndNoMore", + ), + ( + # A hundred pixels of slack under the first form: the band of nothing + # O202 is about, back under a different number - and since 2026-09-21 + # the band the owner saw under the single batch screen, when the + # height came from the tallest screen instead of the first. + "the first opening is a hundred pixels taller than the first screen wants", + "internal/gui/window/opening.go", + "parts.AboveTheScreens(tabbed)+first.Unscrolled()+2*theme.Padding())", + "parts.AboveTheScreens(tabbed)+first.Unscrolled()+2*theme.Padding()+100)", + "TestTheFirstOpeningShowsTheFirstScreenWholeAndNoMore", + ), + # --- a table names its columns once, over the first row (2026-09-16) --- + ( + # The header stops naming anything: every column gets the blank a + # non field gets, so the table has rows and no names over them. + "a table's header draws a blank over every column", + "internal/gui/parts/table.go", + "heads = append(heads, headingRow(f.Label, f.detail, t.fields.required[f.Setting]))", + "heads = append(heads, Clear())", + "TestATableNamesItsColumnsOnceOverTheFirstRow", + ), + ( + # The names are there and stand over the wrong columns: a blank in + # front shifts every name one column to the right of its cells. + "a table's names stand one column to the right of their cells", + "internal/gui/parts/table.go", + "return Row(heads...)", + "return Row(append([]fyne.CanvasObject{Clear()}, heads...)...)", + "TestATableNamesItsColumnsOnceOverTheFirstRow", + ), + ( + # A menu is made without the window's placeholder, so the toolkit puts + # its own in when the menu is first drawn - the second width the two + # row table showed (152 px over 140), and an English word in a window + # speaking another language. Until 2026-10-06 this measured the + # placeholder in menuWidth, which stopped proving anything once every + # menu was made with a placeholder that drawing does not change. + "a menu is made without the window's placeholder", + "internal/gui/parts/ring.go", + "\tc.PlaceHolder = text.PlaceholderChooseOne()\n", + "", + "TestAMenuIsOneWidthBeforeAndAfterItIsDrawn", + ), + ( + # The same defect asked on the screens: the format menu is one width + # on the single batch screen and another on the preset screen. + "the format menu is two widths on two screens", + "internal/gui/parts/ring.go", + "var widest float32\n\tfor _, option := range c.Options {", + "widest := fyne.MeasureText(c.PlaceHolder, size, fyne.TextStyle{}).Width\n\tfor _, option := range c.Options {", + "TestOneSettingIsOneMenuWidthOnEveryScreen", + ), + ( + # Every row's cells slide one column to the right of the names: the + # header stands where it stood and the first row does not. + "a table's rows stand one column to the right of the names", + "internal/gui/parts/table.go", + "return Column(GapTight, append([]fyne.CanvasObject{Row(bodies...)}, areas...)...)", + "return Column(GapTight, append([]fyne.CanvasObject{Row(append([]fyne.CanvasObject{Clear()}, bodies...)...)}, areas...)...)", + "TestATableNamesItsColumnsOnceOverTheFirstRow", + ), + ( + # And the two rows of one table come out two widths: the first row's + # menu has been drawn once, the second's has not. + "the first row of a table is wider than the second", + "internal/gui/parts/ring.go", + "var widest float32\n\tfor _, option := range c.Options {", + "widest := fyne.MeasureText(c.PlaceHolder, size, fyne.TextStyle{}).Width\n\tfor _, option := range c.Options {", + "TestEveryRowOfATableStandsUnderTheSameNames", + ), + ( + # The other direction: the catalogue vouches for a part nobody has. + "the catalogue covers a name parts does not export", + "internal/gui/catalogue/words.go", + 'Covers: []string{"Divider", "ActionBar"}', + 'Covers: []string{"Dividers", "ActionBar"}', + "TestTheCatalogueCoversEveryPartAndNothingElse", + ), + ( + # A fold is built open, so a "closed" state that forgets to close it + # is a caption over the open one's picture - the defect the first + # render of this catalogue had, put back. + "two states of one entry draw the same picture", + "internal/gui/catalogue/fields.go", + "\t\t{\"closed\", func() fyne.CanvasObject {\n\t\t\tf := parts.NewFolding(\"Notes for the manifest\", nil, parts.Prose(\"inside the fold\"))\n\t\t\tf.Set(false)", + "\t\t{\"closed\", func() fyne.CanvasObject {\n\t\t\tf := parts.NewFolding(\"Notes for the manifest\", nil, parts.Prose(\"inside the fold\"))\n\t\t\tf.Set(true)", + "TestEveryCatalogueStateDrawsDifferently", + ), + ( + # The screen skips the first entry of the registry and looks complete. + "the catalogue screen leaves an entry out", + "internal/gui/catalogue/screen.go", + "\tfor _, e := range entries {", + "\tfor _, e := range entries[1:] {", + "TestTheCatalogueScreenShowsTheWholeRegistry", + ), + ( + # The build with no window in it answers the catalogue flag with a + # zero, which is a script being told the window opened. + "a build with no window exits zero when asked for the catalogue", + "internal/gui/run_nocgo.go", + "func run(_ Launch, errOut io.Writer) int {\n\tfmt.Fprintln(errOut, text.NoWindowInThisBuild)\n\treturn 1\n}", + "func run(launch Launch, errOut io.Writer) int {\n\tif launch.Catalogue {\n\t\treturn 0\n\t}\n\tfmt.Fprintln(errOut, text.NoWindowInThisBuild)\n\treturn 1\n}", + "TestABuildWithNoWindowInItSaysSoAndKeepsStandardOutputEmpty", + ), + ( + # The catalogue drops off the list of stored scenes, and every other + # scene goes on passing. + # Renamed rather than removed, because removing the line leaves the + # import unused and the tree does not compile, which proves nothing. + "the catalogue is no longer a stored scene", + "internal/guard/screenpixels_test.go", + "\t\t{name: \"catalogue\", page: catalogue.Page},", + "\t\t{name: \"parts\", page: catalogue.Page},", + "TestTheCatalogueIsAStoredScene", + ), + ( + # O218 put back at the seam: the toolkit gave the window no window + # and the loop is run anyway - on the guest that is the process that + # lives forever with nothing on screen. + "a window with no native window is run instead of refused", + "internal/gui/opening.go", + "\tif !Shown(w) {", + "\tif !Shown(w) && false {", + "TestAWindowTheToolkitGaveNoWindowIsRefusedAndNeverRun", + ), + ( + # The state read upside down: a zero handle passes as a window and a + # real one is refused. + "a zero native handle counts as a window", + "internal/gui/opening.go", + "\treturn handle != 0\n}", + "\treturn handle == 0\n}", + "TestAWindowTheToolkitGaveNoWindowIsRefusedAndNeverRun", + ), + ( + # Cannot tell taken as failed: a driver answering with a context this + # code does not read would refuse a window that opened. + "an unknown native context is refused", + "internal/gui/opening.go", + "\tif !answered {\n\t\treturn true\n\t}", + "\tif !answered {\n\t\treturn false\n\t}", + "TestAWindowTheToolkitGaveNoWindowIsRefusedAndNeverRun", + ), + ( + # The toolkit's own sentence about the driver dropped on the way to + # the person - the refusal still stands, but says nothing about why. + "the cause line is found and thrown away", + "internal/gui/opening.go", + "\t\t\treturn strings.TrimSpace(after)", + "\t\t\treturn strings.TrimSpace(after)[:0]", + "TestTheToolkitsOwnReasonReachesTheSentenceAndItsAbsenceDoesNotBreakIt", + ), + ( + # The sentence with a cause stops quoting it. + "the refusal stops quoting what the toolkit said", + "internal/gui/text/text.go", + "The toolkit said: {{.Cause}}. ", + "The toolkit said: . ", + "TestTheToolkitsOwnReasonReachesTheSentenceAndItsAbsenceDoesNotBreakIt", + ), + ( + # ShowAndRun put back in front of the seam: the loop nothing ends + # runs before the question is ever asked. + "the window binary shows and runs before the seam", + "internal/gui/run_cgo.go", + "\tcode := OpenOrRefuse(w, a.Run, SecondAttemptFor(launch, errOut).Try, func(why error) {", + "\tw.ShowAndRun()\n\tcode := OpenOrRefuse(w, a.Run, SecondAttemptFor(launch, errOut).Try, func(why error) {", + "TestTheWindowBinaryOpensThroughTheRefusalSeam", + ), + ( + # The copy of the toolkit's log put second: a multi-writer stops at + # the first writer that fails, and a windows-subsystem binary's + # standard error is one, so the cause never reaches the copy. + "the log copy is written after the stream that is not there", + "internal/gui/run_cgo.go", + "\tlog.SetOutput(io.MultiWriter(&said, log.Writer()))", + "\tlog.SetOutput(io.MultiWriter(log.Writer(), &said))", + "TestTheWindowBinaryOpensThroughTheRefusalSeam", + ), + ( + # An outside review of the pull request: the copy of the log goes + # into SOME buffer, first, and the one the cause is read from stays + # empty - the guard asked only for an address. + "the log is copied into a buffer nobody reads", + "internal/gui/run_cgo.go", + "\tvar said bytes.Buffer\n\tlog.SetOutput(io.MultiWriter(&said, log.Writer()))", + "\tvar said, other bytes.Buffer\n\tlog.SetOutput(io.MultiWriter(&other, log.Writer()))", + "TestTheWindowBinaryOpensThroughTheRefusalSeam", + ), + ( + # A second change in the copy of the OpenGL binding, in a file the + # compiler never reads - chosen so the entry costs seconds. Any + # change in a file it does read recompiles the binding, the toolkit + # and the guard binary, measured at 3 min 56 s, which the ceiling + # would cap. The licence file is the copy's own, carried unchanged. + "the copy of the binding carries a change its note does not describe", + "third_party/go-gl-gl/LICENSE", + "Copyright (c) 2014 Eric Woroshow", + "Copyright (c) 2014 Eric Woroshow and others", + "TestTheOpenGLBindingIsThePinnedModulePlusExactlyThePatch", + ), + ( + # The sum the note pins for the published version, changed by one + # character. go.sum stopped carrying this module's sum when it was + # replaced, so the note is where the pin lives now. + "the note pins a different sum than the published module has", + "third_party/go-gl-gl/PATCH.md", + "`h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=`", + "`h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHJ=`", + "TestTheOpenGLBindingIsThePinnedModulePlusExactlyThePatch", + ), + ( + # The walk stops leaving a nested module alone: every guard reading + # the shape of this module's code would be handed 2.3 MB of somebody + # else's generated code. + "a nested module is walked as a package of this one", + "internal/guard/packages_test.go", + "\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t}", + "\t\t\t\treturn nil\n\t\t\t}\n\t\t}", + "TestANestedModuleIsNotAPackageOfThisModule", + ), + ( + # The second process starts a third: the flag it was started with + # no longer says "this is the attempt", so a guest where the + # renderer does not help either starts processes until something + # else stops it. + "the second process starts a third", + "internal/gui/software.go", + "\tif a.Launch.SoftwareGL {\n\t\treturn 0, alreadySoftware{}", + "\tif a.Launch.SoftwareGL && false {\n\t\treturn 0, alreadySoftware{}", + "TestTheSecondAttemptIsTakenOnlyWhereTheRendererCanBe", + ), + ( + # The flag dropped from the second process's arguments: it starts as + # an ordinary window, the driver refuses again, and it starts a third. + "the second process is started without the flag", + "internal/gui/software.go", + "code, err := a.Start(append(append([]string{}, a.Launch.Args...), SoftwareFlag))", + "code, err := a.Start(append([]string{}, a.Launch.Args...))", + "TestTheSecondAttemptIsTakenOnlyWhereTheRendererCanBe", + ), + ( + # A system the renderer does not ship for starts a second process + # anyway, looking for a file nobody promised there. + "the attempt is taken on a system nothing ships for", + "internal/gui/software.go", + "\tif a.GOOS != \"windows\" {\n\t\treturn 0, notShipped{a.GOOS}", + "\tif a.GOOS != \"windows\" && false {\n\t\treturn 0, notShipped{a.GOOS}", + "TestTheSecondAttemptIsTakenOnlyWhereTheRendererCanBe", + ), + ( + # The refusal loses the sentence about the missing file, which is + # the one that tells the person what to put back. + "the refusal says nothing about the file that is missing", + "internal/gui/software.go", + "\tcase errors.As(why, &beside):\n\t\treturn text.RendererNotBeside(beside.path)", + "\tcase errors.As(why, &beside) && false:\n\t\treturn text.RendererNotBeside(beside.path)", + "TestTheSecondAttemptIsTakenOnlyWhereTheRendererCanBe", + ), + ( + # The loader before the renderer: the loader imports the renderer + # by name and finds nothing mapped, measured 2026-09-17. + "the renderer's files are named loader first", + "internal/gui/software.go", + "\t\tfilepath.Join(exeDir, softwareDir, softwareRenderer),\n\t\tfilepath.Join(exeDir, softwareDir, softwareLoader),", + "\t\tfilepath.Join(exeDir, softwareDir, softwareLoader),\n\t\tfilepath.Join(exeDir, softwareDir, softwareRenderer),", + "TestTheRendererIsLoadedRendererFirstFromBesideTheProgram", + ), + ( + # The driver left unnamed: the renderer picks one itself and the + # process dies in under three seconds on a machine with a card. + # Windows only, like the file - the runner runs there. + "the driver variable is set to nothing", + "internal/gui/software_windows.go", + "\tif err := os.Setenv(softwareDriver, softwareLLVMPipe); err != nil {", + "\tif err := os.Setenv(softwareDriver, softwareLLVMPipe[:0]); err != nil {", + "TestTheRendererIsLoadedRendererFirstFromBesideTheProgram", + ), + ( + # The seam throws the second process's exit code away. + "the second process's exit code is replaced by one", + "internal/gui/opening.go", + "\t\tif why == nil {\n\t\t\treturn code\n\t\t}", + "\t\tif why == nil {\n\t\t\treturn 1 + 0*code\n\t\t}", + "TestAWindowTheToolkitGaveNoWindowIsRefusedAndNeverRun", + ), + ( + # The About screen stops saying the window is drawn in software. + "the about screen says nothing about the software renderer", + "internal/gui/window/about.go", + "\tif h.SoftwareRendering() {", + "\tif h.SoftwareRendering() && false {", + "TestTheAboutScreenSaysWhenTheWindowIsDrawnInSoftware", + ), + ( + # The real window stops taking the second attempt: a guest with no + # driver gets the refusal and never the renderer. Behind cgo, so + # the guard reads the source. + "the real window never takes the second attempt", + "internal/gui/run_cgo.go", + "SecondAttemptFor(launch, errOut).Try, func(why error) {", + "func() (int, error) { return 0, alreadySoftware{} }, func(why error) {", + "TestTheWindowBinaryOpensThroughTheRefusalSeam", + ), + ( + # The flag loads nothing. + "a window asked for the renderer never loads it", + "internal/gui/run_cgo.go", + "\t\t\terr = LoadSoftwareRenderer(dir)", + "\t\t\terr = notShipped{dir}", + "TestTheWindowBinaryOpensThroughTheRefusalSeam", + ), + ( + # A spawn through the package the guard did not read until + # 2026-09-17. The canary carries the case, and this entry proves + # the canary reaches it: with os taken back out, the scanner + # passes the canary's os.StartProcess and the canary says so. + "os.StartProcess walks past the telemetry scanner again", + "internal/guard/notelemetry_test.go", + "\t\"os\": true,\n}", + "\t\"os\": false,\n}", + "TestTheTelemetryScannerRejectsCodeItMustReject", + ), + ( + # The spawn allowance forgives every file, not the one named: the + # registry is read by one file's name whatever file is being read. + # Until the second outside review of #109 this entry set the flag + # for every file, which no longer opens anything - the operation is + # matched by call and source, so a file that is not again.go would + # still have to make again.go's exact spawn to be forgiven. This is + # that file. + "any file may start a process", + "internal/guard/notelemetry_test.go", + "\tspawn, spawns := spawnsAllowed[rel]", + "\tspawn, spawns := spawnsAllowed[\"internal/gui/again.go\"]", + "TestARegisteredFileIsForgivenOneOperationAndNoMore", + ), + ( + # The flag read and thrown away: a window asked for the renderer + # opens as an ordinary one. + "the software flag is read as nothing", + "internal/gui/gui.go", + "\t\tcase SoftwareFlag:\n\t\t\tlaunch.SoftwareGL = true", + "\t\tcase SoftwareFlag:\n\t\t\tlaunch.SoftwareGL = false", + "TestTheLaunchLineIsReadAndKeptWhole", + ), + ( + # One licence of the companion loses its text in the notices - the + # heading the guard looks for is gone, and with it the text. + "the notices stop reproducing one licence the renderer ships under", + "THIRD-PARTY-NOTICES.md", + "### BSL-1.0\n", + "### Boost\n", + "TestTheNoticesNameWhatShipsBesideTheWindow", + ), + ( + # The registry names a file the program does not load, so the + # archive would be packed with the wrong name. + "the registry names a file the window never loads", + "internal/legal/companions.go", + "{Path: \"opengl/libgallium_wgl.dll\", Size: 61734400,", + "{Path: \"opengl/libgallium.dll\", Size: 61734400,", + "TestTheRegistryNamesTheFilesTheWindowLoads", + ), + ( + # The bill of materials says the window CONTAINS the renderer, + # which it does not - the archive carries it beside. + "the SBOM says the window contains the renderer", + "internal/legal/spdx.go", + "\t\tRelationshipType: \"DEPENDS_ON\",", + "\t\tRelationshipType: \"CONTAINS\",", + "TestTheSBOMShipsTheCompanionBesideTheWindowOnly", + ), + ( + # The companion joins the window's list as ordinary code: it lands + # under the wrong heading and the count of what ships beside is nought. + "the companion is listed as code compiled in", + "internal/legal/carried.go", + "SPDX: c.SPDX, Copyright: c.Copyright, Beside: true})", + "SPDX: c.SPDX, Copyright: c.Copyright, Beside: false})", + "TestTheWindowListsTheCompanionAndTheCommandLineDoesNot", + ), + ( + # The pin the workflow downloads from moves without the registry the + # notices are rendered from: the archive would carry one release and + # the notices describe another. + "the renderer pin names a release the registry does not", + ".github/mesa-dist-win", + "version=26.2.0", + "version=26.2.1", + "TestTheRendererPinAgreesWithTheRegistry", + ), + ( + # The archive's sum no longer checked before unpacking: whatever was + # downloaded goes beside the program. + "the fetch script unpacks a download it never summed", + ".github/scripts/fetch_software_renderer.sh", + '(cd "${fetched}" && echo "${sha256} ${archive}" | sha256sum -c -)', + '(cd "${fetched}" && echo "${sha256} ${archive}" > /dev/null)', + "TestTheFetchScriptChecksTheSumBeforeItUnpacks", + ), + ( + # The release packs the window without the renderer beside it. + "the release forgets the renderer", + ".github/workflows/release.yml", + ' .github/scripts/fetch_software_renderer.sh "${work}"', + ' true # .github/scripts/fetch_software_renderer.sh "${work}"', + "TestEveryWindowBuildFetchesTheRendererBeforeItPacks", + ), + ( + # The libraries beside the program go out unsigned. + "the signing skips the libraries", + ".github/scripts/sign_release.py", + ' signed = programs + [n for n in inside if n.endswith(".dll")]', + ' signed = programs + [n for n in inside if n.endswith(".dylib")]', + "TestTheSigningRepacksWholeAndSignsTheLibraries", + ), + ( + # The published archives are checked for the program's signature and + # nobody asks the libraries. + "the verification stops asking the libraries who signed them", + ".github/workflows/verify-release.yml", + "-Include *.exe, *.dll)", + "-Include *.exe)", + "TestThePublishedWindowArchiveIsCheckedForTheRenderer", + ), + ( + # The flag on a system nothing ships for names the system as if it + # were the error: "could not be loaded: linux" - the first version. + "the flag on a system nothing ships for reports the system as an error", + "internal/gui/software.go", + "\tcase errors.As(loaded, &shipsNot):\n\t\treturn text.RendererNotShipped()", + "\tcase errors.As(loaded, &shipsNot) && false:\n\t\treturn text.RendererNotShipped()", + "TestAWindowAskedForTheRendererSaysWhatBecameOfIt", + ), + ( + # The exception for the copy of the binding widened to the directory + # above it: every future copy would pass the licence gate unread. + "the dependency gate forgives every directory under third_party", + ".github/scripts/dependency_gate.py", + ' "./third_party/go-gl-gl": "a copy of github.com/go-gl/gl, MIT, held to the published module plus one patch by a guard",', + ' "./third_party/go-gl-gl": "a copy of github.com/go-gl/gl, MIT, held to the published module plus one patch by a guard",\n "./third_party/something-else": "",', + "TestTheDependencyGateRefusesALicenceThisProjectCannotDistribute", + ), + ( + # The spawn allowance forgives every spawn in the registered file + # again, not the one its entry was written for. + "a registered file is forgiven every spawn", + "internal/guard/notelemetry_test.go", + "\t\t\tspawns = false\n\t\t\tcontinue", + "\t\t\tcontinue", + "TestARegisteredFileIsForgivenOneOperationAndNoMore", + ), + ( + # The release-download exemption widens from the fetch script to + # every file, and a download link in SECURITY.md is forgiven. + "any file may link to somebody else's release download", + "internal/guard/community_test.go", + "\t\tif rel == rendererFetchScript && download && companionProjects()[project] {", + "\t\tif download && companionProjects()[project] {", + "TestAReleaseDownloadIsForgivenInTheFetchScriptAlone", + ), + ( + # The exemption goes back to matching the script's NAME, so a second + # script of that name anywhere under .github is forgiven with it - + # the second outside review of #109. + "a script of the same name anywhere may link to a release download", + "internal/guard/community_test.go", + "\t\tif rel == rendererFetchScript && download && companionProjects()[project] {", + "\t\tif strings.HasSuffix(rel, \"/fetch_software_renderer.sh\") && download && companionProjects()[project] {", + "TestAReleaseDownloadIsForgivenInTheFetchScriptAlone", + ), + ( + # The fetch script may download anybody's release, not the project + # the companion registry names. + "the fetch script may download any project's release", + "internal/guard/community_test.go", + "\t\tif rel == rendererFetchScript && download && companionProjects()[project] {", + "\t\tif rel == rendererFetchScript && download {", + "TestAReleaseDownloadIsForgivenInTheFetchScriptAlone", + ), + ( + # A file that cannot be read is called missing, and the person is + # told to put back a folder that is there. + "a renderer file that cannot be read is reported as missing", + "internal/gui/software.go", + "\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn path, nil\n\t\t}\n\t\treturn \"\", err", + "\t\tif errors.Is(err, fs.ErrNotExist) || true {\n\t\t\treturn path, nil\n\t\t}\n\t\treturn \"\", err", + "TestARendererFileThatCannotBeReadIsNotReportedAsMissing", + ), + # --- the second outside review of #109 (2026-09-17, afternoon) --- + ( + # The walk moves into a docstring, which the first reader left in: + # the text is in the file and the repack no longer walks. + "the repack's walk survives only as a docstring", + ".github/scripts/sign_release.py", + " for base, _dirs, names in os.walk(work):", + " \"\"\"for base, _dirs, names in os.walk(work):\"\"\"\n for base, _dirs, names in os.scandir(work):", + "TestTheSigningRepacksWholeAndSignsTheLibraries", + ), + ( + # The count moves into a short string literal on a line of its own. + "the repack's count survives only inside a string literal", + ".github/scripts/sign_release.py", + " if repacked != inside:", + " note = \"if repacked != inside:\"\n if repacked is None:", + "TestTheSigningRepacksWholeAndSignsTheLibraries", + ), + ( + # The certificate check commented out AFTER code on the same line, + # which a reader of whole comment lines does not see. + "the certificate check survives only as a trailing comment", + ".github/scripts/sign_release.py", + " actual = certificate_of(target)", + " actual = pin # actual = certificate_of(target)", + "TestTheSigningRepacksWholeAndSignsTheLibraries", + ), + ( + # The pin constant names another file while three error messages go + # on naming codesign.go: the text is in the script four times, and a + # guard asking whether it is in the script at all stayed green. + "the pin constant names another file and the messages keep the old name", + ".github/scripts/sign_release.py", + "PIN_FILE = os.path.join(\"internal\", \"legal\", \"codesign.go\")", + "PIN_FILE = os.path.join(\"internal\", \"legal\", \"codesign.txt\")", + "TestTheSigningScriptRefusesBeforeItSigns", + ), + ( + # The notes go back to naming the usual cause as the condition, which + # is not the test the program makes. + "the release notes say the renderer is used when the driver offers no OpenGL 2.1", + ".github/workflows/release.yml", + " echo \"in \\`opengl\\` next to the program. The window loads it only after its first attempt\"", + " echo \"in \\`opengl\\` next to the program. The window loads it only when the graphics driver\"", + "TestThePublishedWindowArchiveIsCheckedForTheRenderer", + ), + ( + # The allowance forgives any call handed the approved argument. + "a registered file is forgiven any call handed the approved argument", + "internal/guard/approvedoperation_test.go", + "\treturn f.call == op.call && f.from == op.from", + "\treturn f.from == op.from", + "TestARegisteredFileIsForgivenOneOperationAndNoMore", + ), + ( + # The allowance forgives the approved call handed anything. + "a registered file is forgiven the approved call handed anything", + "internal/guard/approvedoperation_test.go", + "\treturn f.call == op.call && f.from == op.from", + "\treturn f.call == op.call", + "TestARegisteredFileIsForgivenOneOperationAndNoMore", + ), + ( + # A name bound twice on the way to the call is followed to its + # first binding, and the second is what runs. + "an argument rebound on the way to the call is followed to its first binding", + "internal/guard/approvedoperation_test.go", + "\tif len(sources) != 1 {", + "\tif len(sources) == 0 {", + "TestARegisteredFileIsForgivenOneOperationAndNoMore", + ), + ( + # The reader of the signing script stops dropping docstrings, and an + # operation named in one counts as the operation again. + "the signing script reader keeps docstrings", + "internal/guard/mesarelease_test.go", + "\t\tcase strings.HasPrefix(rest, `\"\"\"`) || strings.HasPrefix(rest, `'''`):", + "\t\tcase false:", + "TestTheSigningScriptReaderKeepsCodeAndDropsProse", + ), + ( + # The reader keeps a comment that follows code on the same line. + "the signing script reader keeps a trailing comment", + "internal/guard/mesarelease_test.go", + "\t\tcase rest[0] == '#':", + "\t\tcase rest[0] == '#' && false:", + "TestTheSigningScriptReaderKeepsCodeAndDropsProse", + ), + ( + # The window starts a program that is not the one os.Executable + # answered: the registered file keeps its one spawn and the guard + # has to see that it is not the approved one. + "the second process is not the program os.Executable answered", + "internal/gui/again.go", + "\tcmd := exec.Command(exe, args...)", + "\tcmd := exec.Command(exe[:0]+os.Args[0], args...)", + "TestNothingShippedHoldsAnUndeclaredWayOut", + ), + ( + # The renderer is loaded by a name worked out in place rather than + # by the path SoftwareFiles answers - the search order again, in the + # registered file, as its one computed load. + "the renderer is loaded by a name rather than by the path SoftwareFiles answers", + "internal/gui/software_windows.go", + "\t\tif _, err := syscall.LoadDLL(path); err != nil {", + "\t\tif _, err := syscall.LoadDLL(softwareLoader + path[:0]); err != nil {", + "TestALibraryIsOnlyLoadedLazilyWhenTheSystemAlreadyHasIt", + ), + # --- O221: the whole head row of a fold is one target (2026-09-17) --- + ( + # A press on the row does nothing - which is what a press on the + # title did until this change, with the arrow the only target. + "a press on the head row leaves the fold as it was", + "internal/gui/parts/foldhead.go", + "\th.fold.Set(!h.fold.open)\n}", + "\th.fold.Set(h.fold.open)\n}", + "TestTheWholeHeadRowOfAFoldOpensAndShutsIt", + ), + ( + # The row answers the space character as well as the space key, so + # one press of the space bar opens and shuts - the shape one press + # of Space on Add batch had on 2026-09-16. + "the head row answers the space bar twice", + "internal/gui/parts/foldhead.go", + "func (h *FoldHead) TypedRune(rune) {}", + "func (h *FoldHead) TypedRune(rune) { h.Tapped(nil) }", + "TestTheKeyboardOpensAndShutsAFoldOncePerPress", + ), + ( + # The keyboard arrives and the row draws nothing to say so. + "the head row holds the keyboard without a mark", + "internal/gui/parts/foldhead.go", + "\th.marked = true\n\th.Refresh()\n}\n\nfunc (h *FoldHead) FocusLost() {", + "\th.marked = false\n\th.Refresh()\n}\n\nfunc (h *FoldHead) FocusLost() {", + "TestTheKeyboardOpensAndShutsAFoldOncePerPress", + ), + ( + # The fill under the pointer is never drawn: hovered is set and not + # painted, which looks exactly like painted to every guard that + # reads the flag. + "the head row under the pointer draws no fill", + "internal/gui/parts/foldhead.go", + "\tif r.head.hovered {\n\t\tr.back.FillColor", + "\tif r.head.hovered && false {\n\t\tr.back.FillColor", + "TestTheHeadRowDrawsItsStatesAndTheArrowFollows", + ), + ( + # The ring for the keyboard is never drawn. + "the head row holding the keyboard draws no ring", + "internal/gui/parts/foldhead.go", + "\tif r.head.marked {\n\t\tr.ring.StrokeWidth = ringWidth", + "\tif r.head.marked && false {\n\t\tr.ring.StrokeWidth = ringWidth", + "TestTheHeadRowDrawsItsStatesAndTheArrowFollows", + ), + ( + # The arrow stops following the row: inked the same under the pointer + # as at rest. + "the arrow does not brighten with the row under the pointer", + "internal/gui/parts/foldhead.go", + "\tif h.hovered {\n\t\tink = theme.ColorNameForeground", + "\tif h.hovered && false {\n\t\tink = theme.ColorNameForeground", + "TestTheHeadRowDrawsItsStatesAndTheArrowFollows", + ), + ( + # The fill under the pointer runs the width of the row again - the + # owner's "enormous" hover of 2026-09-21. + "the head's fill under the pointer is as wide as the row rather than as the words", + "internal/gui/parts/foldhead.go", + "\tif r.head.under != nil {", + "\tif r.head.under != nil && false {", + "TestTheHeadRowDrawsItsStatesAndTheArrowFollows", + ), + ( + # The arrow points down whatever the fold is doing. + "the arrow points the same way open and shut", + "internal/gui/parts/foldhead.go", + "\tif !h.fold.open {\n\t\ticon = theme.MenuExpandIcon()", + "\tif !h.fold.open && false {\n\t\ticon = theme.MenuExpandIcon()", + "TestTheHeadRowDrawsItsStatesAndTheArrowFollows", + ), + ( + # The row no longer overhangs the column, so the room it keeps inside + # its box moves the title off the edge its fields stand on. + "the head row stops overhanging and the title moves off the edge", + "internal/gui/parts/folding.go", + "container.New(overhang{by: TabInset}, row)", + "container.New(overhang{by: 0}, row)", + "TestTheHeadRowOverhangsTheColumnAndTheTitleDoesNot", + ), + # --- after the outside review of #110 (2026-09-17) --- + ( + # The row stops drawing the ring on the first key after a press - the + # state it shipped in on the PR's first commit, found by asking the + # row's siblings rather than the review. + "the head row draws no ring on the first key after a press", + "internal/gui/parts/foldhead.go", + "\tif !h.marked {\n\t\th.marked = true\n\t\th.Refresh()\n\t}\n\tswitch event.Name {", + "\tswitch event.Name {", + "TestAKeyAfterAPressOnTheHeadRowDrawsTheRing", + ), + ( + # The one place a press takes the keyboard stops being quiet, so every + # control of the family draws its mark for the mouse again - the + # 2026-08-12 menu painted blue, for all of them at once. + "a press takes the keyboard with the mark drawn, for every control", + "internal/gui/parts/focus.go", + "\tp.Quietly(func() { canvas.Focus(control) })", + "\tcanvas.Focus(control)", + "TestAPressMovesTheKeyboardWithoutDrawingItsMark", + ), + ( + # The head row of a fold is measured on its own width again - the + # width the HBox hands it is the width of its own words, so this half + # of the guard was whole by construction until 2026-09-17. + "a fold's head row is measured on its own width rather than the room it has", + "internal/guard/oneline_test.go", + "row.MinSize().Width, row", + "row.Size().Width, row", + "TestAFoldsHeadRowIsMeasuredAsAWholeAgainstItsWidth", + ), + ( + # The pairing check goes: a head the walk could not pair with its + # words is stepped over, and a fold built another way is a fold this + # guard is silently not measuring. + "a fold's head with no words beside it is stepped over rather than refused", + "internal/guard/oneline_test.go", + "\tif heads != rows {", + "\tif heads != rows && false {", + "TestAFoldsHeadRowIsMeasuredAsAWholeAgainstItsWidth", + ), + # --- the owner's nine points from the running window (2026-09-21) --- + ( + # Point 1 put back: the explanation on the panel's surface, which over + # a panel is a sentence with no edge anywhere. + "the explanation stands on the panel colour again", + "internal/gui/parts/detail.go", + "\tbox := container.NewStack(append(floatingCard(), Padded(Inset, Prose(detail)))...)", + "\tbox := container.NewStack(panelSurface(), Padded(Inset, Prose(detail)))", + "TestTheExplanationFloatsOnTheSurfaceAnOpenListDoes", + ), + ( + # Point 6 put back: the tick a step inside the square on every side, + # which drew it 7 px across in a square of 20. + "the tick of a switch keeps a margin inside its square again", + "internal/gui/parts/toggle.go", + "\tr.tick.Resize(square)\n\tr.tick.Move(at)", + "\tr.tick.Resize(fyne.NewSquareSize(markSide - GapInline*2))\n\tr.tick.Move(at.Add(fyne.NewPos(GapInline, GapInline)))", + "TestTheTickOfASwitchFillsItsSquare", + ), + ( + # Point 2 put back: upward as soon as the list does not fit below and + # there is more room above - the preset screen's format list over the + # question the preset asks. + "a list turns upward as soon as there is more room above the box", + "internal/gui/parts/ring.go", + "\tif rows <= below || below >= listOpensDownwardFrom*listRowHeight() || below >= above {", + "\tif rows <= below || below >= above {", + "TestAListOpensDownwardWheneverAFewRowsFitUnderTheBox", + ), + ( + # The cut stops landing on a row's edge: the room below is taken to + # the pixel, and the last row of the list is cut through the middle. + "a list cut to the room below ends through the middle of a row", + "internal/gui/parts/ring.go", + "\tbelow := wholeRows(canvasHeight - (boxTop + boxHeight) - listEdgeGap - head)", + "\tbelow := fyne.Max(0, canvasHeight-(boxTop+boxHeight)-listEdgeGap-head)", + "TestAListOpensDownwardWheneverAFewRowsFitUnderTheBox", + ), + ( + # Point 4 put back: the window coming to the front marks whatever + # holds the keyboard - the one blue menu the owner saw. + "the window coming back draws the mark whether or not it was there", + "internal/gui/parts/focus.go", + "\t\tp.returning = false\n\t\treturn p.wasMarked", + "\t\tp.returning = false\n\t\treturn true", + "TestTheWindowComingBackDrawsOnlyTheMarkThatWasThere", + ), + ( + # The return is never forgotten, so the keyboard arriving plainly + # afterwards answers with the state of the last loss. + "the window's return is never forgotten and mutes the next arrival", + "internal/gui/parts/focus.go", + "\t\tp.returning = false\n\t\treturn p.wasMarked", + "\t\treturn p.wasMarked", + "TestTheWindowComingBackDrawsOnlyTheMarkThatWasThere", + ), + ( + # Point 5 put back: a text box as wide as the column. + "a box for a short piece of text takes the whole column again", + "internal/gui/parts/tokens.go", + # Repointed 2026-09-23, when a short text took one column of the grid. + "\tTextWidth = NumericWidth\n", + "\tTextWidth = ColumnWidth\n", + "TestOnlyAPathTakesTheWholeRow", + ), + ( + # One box forgets to ask for the width: the batch name on the first + # screen takes the row, the way every text box did until today. + "the batch name box on the single batch screen takes the row", + "internal/gui/window/generate.go", + # Repointed 2026-09-23: in the grid an unsized box takes one column, + # so taking the wrapper off no longer widens it. Marked Wide it takes + # the row, which is the defect. + "\t\t\tadd(engine.SettingID, text.FieldTargetID(), text.HintTargetID(), g.tips.Say(text.DetailTargetID()), parts.Text(g.id)),", + "\t\t\tparts.Wide(add(engine.SettingID, text.FieldTargetID(), text.HintTargetID(), g.tips.Say(text.DetailTargetID()), parts.Text(g.id))),", + "TestOnlyAPathTakesTheWholeRow", + ), + ( + # A declared text setting - an archive's password - takes the row. + "a declared free text setting takes the whole row", + "internal/gui/parts/property.go", + # Repointed 2026-09-23: unsized, a box takes one column of the grid, + # so the defect is a width as wide as the form. + "\tcase p.Kind == format.PropertyText:\n\t\treturn Sized(fyne.Max(TextWidth, roomFor(leftAlone(p))), control)\n", + "\tcase p.Kind == format.PropertyText:\n\t\treturn Sized(ColumnWidth, control)\n", + "TestOnlyAPathTakesTheWholeRow", + ), + ( + # Point 8 put back: no fill at rest, an outline round nothing. + # Since 2026-09-22 the face is the button's own surface. The way back + # to the owner's report is the field's colour, which the guard asks + # about by name and by distance. + "a secondary button wears the field's surface again", + "internal/gui/parts/button.go", + "\t\tf.fill = PaletteColour(theme.ColorNameButton, dark)", + "\t\tf.fill = PaletteColour(theme.ColorNameInputBackground, dark)", + "TestASecondaryButtonWearsAFaceAtRest", + ), + ( + # Point 9 put back: the face at once, which is the flash. + "the busy face goes on the moment work starts", + "internal/gui/window/runbusy.go", + "\tb.callOff = b.later(BusyFaceAfter, func() {", + "\tb.callOff = b.later(0, func() {", + "TestTheBusyFaceWaitsForWorkThatLasts", + ), + ( + # The clock is bypassed: the face is worn at once and the clock is + # asked for nothing. + "the busy face is worn without asking the clock", + "internal/gui/window/runbusy.go", + "\tb.callOff = b.later(BusyFaceAfter, func() {\n\t\t// The same work still going, and not already worn: a face asked for\n\t\t// by earlier work is a face for a screen that has moved on, and a\n\t\t// face put on twice would be taken off once.\n\t\tif b.epoch == mine && b.occupied && !b.worn {\n\t\t\tb.wear(face)\n\t\t}\n\t})", + "\t_ = mine\n\tb.wear(face)\n\tb.callOff = func() {}", + "TestTheBusyFaceWaitsForWorkThatLasts", + ), + ( + # A second press inside the first moment is refused by nothing: the + # button is still live and the state is not asked. + "a second Preview inside the first moment starts a second preview", + "internal/gui/window/run.go", + "\t// The same refusal as onGenerate, for the same moment.\n\tif r.busy.occupied {\n\t\treturn\n\t}\n", + "", + "TestTheBusyFaceWaitsForWorkThatLasts", + ), + ( + # Work ending does not call the face off, so a clock firing later + # dresses an idle screen. + "work ending leaves the clock running", + "internal/gui/window/runbusy.go", + "\tif b.callOff != nil {\n\t\tb.callOff()\n\t\tb.callOff = nil\n\t}", + "\tb.callOff = nil", + "TestWorkOverBeforeTheClockWearsNoFace", + ), + ( + # The row is left to the toolkit when the face comes off: Preview and + # Generate keep the positions they had with Cancel beside them. + "the row of buttons is not laid out again when the face comes off", + "internal/gui/window/runbusy.go", + "\tb.bar.Hide()\n\tb.relay()", + "\tb.bar.Hide()", + "TestTheButtonsStandWhereTheyStoodAfterAPreview", + ), + ( + # And when it goes on: Cancel is shown and never laid out. + "the row of buttons is not laid out again when the face goes on", + "internal/gui/window/runbusy.go", + "\tif face.progressing {\n\t\tb.bar.Show()\n\t}\n\tb.relay()", + "\tif face.progressing {\n\t\tb.bar.Show()\n\t}", + "TestTheButtonsStandWhereTheyStoodAfterAPreview", + ), + # --- after the outside review of #115 (2026-09-21, evening) --- + ( + # The epoch check is made always true, so a face queued by earlier + # work dresses the work that came after it - the review's finding. + "a face asked for by earlier work dresses the later work", + "internal/gui/window/runbusy.go", + "\t\tif b.epoch == mine && b.occupied && !b.worn {", + "\t\tif b.epoch >= mine && b.occupied && !b.worn {", + "TestAFaceAskedForByEarlierWorkNeverDressesLaterWork", + ), + ( + # The hook stops telling anything: a focused menu is marked on the + # window's return again. + "the foreground hook tells the focused control nothing", + "internal/gui/returning.go", + "\t\treturning.WindowReturning()", + "\t\t_ = returning", + "TestTheForegroundHookTellsTheFocusedControlTheWindowIsBack", + ), + ( + # The registration stays and its body stops calling the hook, so the + # window binary tells nothing on its return - read out of the + # source, because the line is behind cgo. + "the foreground hook is not registered by the window binary", + "internal/gui/run_cgo.go", + "\ta.Lifecycle().SetOnEnteredForeground(func() { WindowReturning(w.Canvas()) })", + "\ta.Lifecycle().SetOnEnteredForeground(func() { _ = w.Canvas() })", + "TestTheWindowBinaryRegistersTheForegroundHook", + ), + ( + # The defect put back exactly: the hook's file imports parts for the + # interface, and the no-cgo half of the window package reaches the + # toolkit's widgets - red on macOS alone until this guard. + "the hook's file imports parts and drags the widgets into the no-cgo build", + "internal/gui/returning.go", + "import \"fyne.io/fyne/v2\"\n", + "import (\n\t\"fyne.io/fyne/v2\"\n\n\t\"github.com/donislawdev/TestingFilesGenerator/internal/gui/parts\"\n)\n\nvar _ parts.Returnable\n", + "TestTheWindowPackageOutsideCgoReachesNoWidget", + ), + ( + "a checkout with a with block of its own keeps the token again, which " + "is the shape a line based search would read wrong", + ".github/workflows/pages.yml", + " with:\n persist-credentials: false\n # The commit CI passed on", + " with:\n # The commit CI passed on", + "TestEveryCheckoutTurnsItsTokenOff", + ), + ( + "the rule reads a boolean true as off, so the self test on the predicate " + "is what has to catch it, not the tree", + "internal/guard/checkoutcredentials_test.go", + "\tcase bool:\n\t\treturn !v\n", + "\tcase bool:\n\t\treturn true\n", + "TestEveryCheckoutTurnsItsTokenOff", + ), + ( + "the YAML walk stops recognising a checkout, so it judges none of them, " + "and only the second reading of the text can say so", + "internal/guard/checkoutcredentials_test.go", + 'if !strings.HasPrefix(step.Uses, "actions/checkout@") {', + 'if !strings.HasPrefix(step.Uses, "actions/checkout@@") {', + "TestEveryCheckoutTurnsItsTokenOff", + ), + ( + "the preset expands into a seed of its own, which a recipe building on it " + "could not tell from the file's", + "internal/preset/build.go", + "\tbody, err := recipe.Compose(recipe.Document{Targets: p.targets})\n", + "\tbody, err := recipe.Compose(recipe.Document{Targets: p.targets, Seed: \"1\"})\n", + "TestAPresetExpandsToTargetsAndNothingElse", + ), + ( + "the rule stops seeing a seed on the preset's side, so the self test on " + "the predicate is what has to catch it", + "internal/recipe/extends.go", + "\tif raw.Seed != nil {\n", + "\tif false {\n", + "TestAPresetExpandsToTargetsAndNothingElse", + ), + ( + "the file's own targets go before the preset's, leaving every byte in " + "place and the manifest in another order", + "internal/recipe/extends.go", + "\tmerged.Targets = append(append([]rawTarget{}, from.Targets...), own.Targets...)\n", + "\tmerged.Targets = append(append([]rawTarget{}, own.Targets...), from.Targets...)\n", + "TestARecipeBuildingOnAPresetGivesTheBytesOfTheEjectedOneWithItsTargetsAppended", + ), + ( + "a run from a file that builds on a preset stops recording which preset, " + "so the manifest reads like one from a file that stands alone", + "internal/cli/generate.go", + "\topt.Preset = record(read.Expansion)\n", + "\topt.Preset = nil\n", + "TestARecipeBuildingOnAPresetGivesTheBytesOfTheEjectedOneWithItsTargetsAppended", + ), + ( + "the batch screen draws the section and drops it on the way to the " + "recipe, so the switch changes nothing the run can see", + "internal/gui/window/recipe.go", + "\tdoc.Extends, doc.With = r.base.draft()\n", + "\t_, _ = r.base.draft()\n", + "TestARecipeBuiltOnAPresetFromTheWindowGivesTheBytesTheFileGives", + ), + ( + "a refusal about a parameter the preset does not take arrives addressed " + "to the preset line rather than to the parameter's, so a form marks the " + "wrong box", + "internal/preset/read.go", + '\t\tproblem.At = recipe.KeyWith + "." + unknownParameter.Name\n', + "\t\tproblem.At = recipe.KeyExtends\n", + "TestEveryRefusalAboutThePresetSideOfARecipeNamesItsLine", + ), + ( + "an id the preset already uses is refused with the words for two targets " + "of the file, which point at a line the file does not have", + "internal/recipe/recipe.go", + "\tif ofThePreset {\n", + "\tif ofThePreset && false {\n", + "TestEveryRefusalAboutThePresetSideOfARecipeNamesItsLine", + ), + ( + "extends stops asking for its scheme, so a file name is handed to the " + "preset registry and refused as an unknown preset instead", + "internal/recipe/extends.go", + '\tif id == value || id == "" {\n', + "\tif false {\n", + "TestEveryRefusalAboutThePresetSideOfARecipeNamesItsLine", + ), + ( + "the door guard stops excusing the command line's preset path, and if " + "the walk reaches that file it has to say so", + "internal/guard/extends_test.go", + '\t\tfilepath.Join("internal", "cli", "preset.go"): true,\n', + '\t\tfilepath.Join("internal", "cli", "preset.go"): false,\n', + "TestEveryRecipeFileIsReadThroughTheDoorThatKnowsPresets", + ), + ( + "the switch stops letting the last batch go, so a preset's set cannot be " + "run alone from the screen - the shape an outside review of #119 found", + "internal/gui/window/recipebase.go", + "func (b *base) carriesTheRun() bool { return b.on.Checked }\n", + "func (b *base) carriesTheRun() bool { return false }\n", + "TestTheBatchScreenCanRunAPresetsSetAlone", + ), + ( + "a file of extends alone handed to the plain reader collects a second " + "refusal about asking for no files, contradicting the README", + "internal/recipe/recipe.go", + "\t\t\t\"read the file with \\\"tfg generate\\\" or \\\"tfg validate\\\", which do\")\n\t\treturn rec\n", + "\t\t\t\"read the file with \\\"tfg generate\\\" or \\\"tfg validate\\\", which do\")\n", + "TestEveryRefusalAboutThePresetSideOfARecipeNamesItsLine", + ), + ( + "the refusal about a joint limit goes back to rounding both counts, so " + "a request just over the limit prints as the limit itself", + "internal/format/format.go", + "\tif j.per() > 1 && got/j.per() != j.Max/j.per() {\n", + "\tif j.per() > 1 {\n", + "TestNoJointLimitRefusalPrintsTheRequestAndTheLimitAsOneNumber", + ), + ( + "the limit in a joint refusal loses its unit again, so it is a bare " + "number taking its noun from four words earlier. Aimed at the guard " + "that reads the SENTENCE - the one checking the declarations stayed " + "green on it, because a declaration with a Base can still be printed " + "without one", + "internal/format/format.go", + "\treturn core.Exactly(got) + \" \" + j.Base, core.Exactly(j.Max) + \" \" + j.Base\n", + "\treturn core.Exactly(got) + \" \" + j.Base, core.Exactly(j.Max)\n", + "TestNoJointLimitRefusalPrintsTheRequestAndTheLimitAsOneNumber", + ), + ( + "a rule binding two settings stops saying what it counts, so its exact " + "refusal ends without a noun", + "internal/format/png/png.go", + '\t\t\tUnit: "megapixels", Per: 1_000_000, Base: "pixels",\n', + '\t\t\tUnit: "megapixels", Per: 1_000_000,\n', + "TestEveryJointLimitSaysWhatItCounts", + ), + ( + "no preset says which one the window opens on, so it falls back to the " + "first id in alphabetical order", + "internal/preset/emptyandminimal.go", + "\t\tLanding: true,\n", + "\t\tLanding: false,\n", + "TestExactlyOnePresetOpensTheWindow", + ), + ( + "the presets screen goes back to the line it had until 2026-09-22 - " + "opening on the FIRST id, which is the regression somebody would " + "actually write. The behavioural guard cannot see it while the declared " + "preset is also first alphabetically, which is why this is aimed at the " + "one reading the source", + "internal/gui/window/preset.go", + "\tif landing := preset.Landing(); landing != \"\" {\n\t\tp.pick.SetSelected(landing)\n\t}\n", + "\tif len(ids) > 0 {\n\t\tp.pick.SetSelected(ids[0])\n\t}\n", + "TestNoScreenChoosesAPresetByItsPlaceInTheList", + ), + ( + "the batch screen goes back to opening on the FIRST id, the same " + "regression one screen over", + "internal/gui/window/recipebase.go", + "\tif landing := preset.Landing(); landing != \"\" {\n\t\tb.pick.SetSelected(landing)\n\t}\n", + "\tif len(ids) > 0 {\n\t\tb.pick.SetSelected(ids[0])\n\t}\n", + "TestNoScreenChoosesAPresetByItsPlaceInTheList", + ), + ( + "a screen opens on a preset nobody declared - the LAST in the list. " + "Kept beside the two above because it is the half the behavioural guard " + "does see, and losing it would leave the screens proven only by a read " + "of their source", + "internal/gui/window/preset.go", + "\t\tp.pick.SetSelected(landing)\n", + "\t\tp.pick.SetSelected(preset.IDs()[len(preset.IDs())-1])\n", + "TestBothScreensOpenOnTheDeclaredPreset", + ), + ( + "the formats are taken in the order they were typed, so one selection " + "written two ways makes two recipes and two recipe_hash values", + "internal/preset/emptyandminimal.go", + "\tfor _, id := range format.IDs() {\n\t\tif !wanted[id] {\n\t\t\tcontinue\n\t\t}\n", + "\tfor _, id := range ids {\n\t\tif false && wanted[id] {\n\t\t\tcontinue\n\t\t}\n", + "TestTheMinimalSetIsTheSameWhateverOrderTheFormatsAreNamedIn", + ), + ( + "one format is laid out twice, which the counts of formats and of empty " + "files cannot see because both of them are counts of a map", + "internal/preset/emptyandminimal.go", + "\t\t\tminimal = append(minimal, minimalFile{desc: d, size: floor})\n\t\t\tcontinue\n", + "\t\t\tminimal = append(minimal, minimalFile{desc: d, size: floor}, minimalFile{desc: d, size: floor})\n\t\t\tcontinue\n", + "TestTheMinimalSetSitsOnEveryFormatsFloor", + ), + ( + "a format is registered under the word that means every format, so the " + "formats parameter can never reach it", + "internal/format/txt/txt.go", + '\t\tID: "txt",\n', + '\t\tID: "all",\n', + "TestNoFormatIsCalledByTheWordThatMeansAllOfThem", + ), + ( + "the minimal set asks for one byte more than the floor, so every file " + "still generates and verifies and none of them is a minimum any more", + "internal/preset/emptyandminimal.go", + "\t\t\tminimal = append(minimal, minimalFile{desc: d, size: floor})\n", + "\t\t\tminimal = append(minimal, minimalFile{desc: d, size: floor + 1})\n", + "TestTheMinimalSetSitsOnEveryFormatsFloor", + ), + ( + "a format that reaches nought bytes stops bringing its one byte file, " + "so two formats quietly leave the positive control", + "internal/preset/emptyandminimal.go", + "\t\tminimal = append(minimal, minimalFile{desc: d, size: 1})\n", + "\t\t_ = d\n", + "TestTheMinimalSetSitsOnEveryFormatsFloor", + ), + ( + "an empty file claims to be accepted rather than left to the policy of " + "whoever reads it", + "internal/preset/emptyandminimal.go", + '\t\t\tExpected: "unspecified", ExpectedReason: "size_zero",\n', + '\t\t\tExpected: "accept",\n', + "TestTheMinimalSetSitsOnEveryFormatsFloor", + ), + ( + "a set built with no empty half goes quiet about it, so a preset named " + "after both halves ships one without a word", + "internal/preset/emptyandminimal.go", + "\tfor _, d := range descs {\n\t\tif smallest(d) == 0 {\n\t\t\treturn nil\n\t\t}\n\t}\n", + "\tif len(descs) >= 0 {\n\t\treturn nil\n\t}\n", + "TestASetWithNoEmptyHalfSaysSoAndOneWithBothStaysQuiet", + ), + ( + "two spellings of one item are compared before either is normalised, " + "so PNG and png become two targets of one id", + "internal/preset/build.go", + "\t\titem := typed\n\t\tif l.keep != nil {\n\t\t\titem = l.keep(typed)\n\t\t}\n\t\tkey := item\n", + "\t\titem := typed\n\t\tkey := item\n\t\tif l.keep != nil {\n\t\t\titem = l.keep(typed)\n\t\t}\n", + "TestAListParameterRefusesTwoSpellingsOfOneItem", + ), + ( + "a composed recipe goes back to the marshaller's flat sequences, so a " + "target pasted in from the documents no longer lines up", + "internal/recipe/compose.go", + "\treturn yaml.MarshalWithOptions(doc, yaml.IndentSequence(true))\n", + "\treturn yaml.Marshal(doc)\n", + "TestAComposedRecipeIsWrittenTheWayAPersonWritesOne", + ), + ( + "every value goes back to being quoted, so a composed recipe writes " + 'size: "1024" where a person writes size: 1024', + "internal/recipe/compose.go", + "\t\t\tentry = append(entry, yaml.MapItem{Key: key, Value: bareNumber(value)})\n", + "\t\t\tentry = append(entry, yaml.MapItem{Key: key, Value: value})\n", + "TestAComposedRecipeIsWrittenTheWayAPersonWritesOne", + ), + ( + "a name made of digits is turned into a number, so a file called 123 " + "loses its name. Not 007 - bareNumber refuses a leading zero anyway, " + "which is how this entry sat green and proved nothing once", + "internal/recipe/compose.go", + '\tadd("name", t.Name)\n', + '\taddNumber("name", t.Name)\n', + "TestAComposedRecipeIsWrittenTheWayAPersonWritesOne", + ), + ( + "the window sends the formats box nowhere, so the screen builds the " + "whole set whatever was typed into it", + "internal/gui/window/preset.go", + "\t\tif v := f.Value(); v != \"\" {\n", + "\t\tif v := f.Value(); v == \"\" {\n", + "TestThePresetScreenBuildsTheMinimalSetTheCommandLineBuilds", + ), + ( + "a group a parameter emptied goes quiet, so a set of seven groups " + "ships as six and nothing in the run says which one is missing", + "internal/preset/uploadvalidation.go", + '\tif s.bulk == 0 {\n\t\tout = append(out, "bulk is nought, so this set holds no mass upload.")\n\t}\n', + "\t_ = s.bulk\n", + "TestAGroupAPresetLeavesOutIsSaidOutLoud", + ), + ( + "a list of formats refused stops naming what this build does have, so " + "somebody who typed heic has nowhere to find out what it takes", + "internal/preset/build.go", + '\t\treturn fmt.Sprintf("this build has no format called that. It has: %s",\n\t\t\tstrings.Join(format.IDs(), ", "))\n', + '\t\treturn "this build has no format called that"\n', + "TestAListOfFormatsRefusedNamesWhatThisBuildHas", + ), + ( + "the file at the limit is renamed, so every manifest written from this " + "preset carries a recipe_hash that no longer matches the one it had", + "internal/preset/limitset.go", + '\tout = append(out, step{id: "at_limit", size: s.limit, accept: true})\n', + '\tout = append(out, step{id: "at_the_limit", size: s.limit, accept: true})\n', + "TestEjectingAPresetGivesTheBytesItAlwaysGave", + ), + ( + "the dialect group stops varying the settings that are switches, so a " + "table with no header row quietly leaves the set", + "internal/preset/tabularimport.go", + "\t\tif p.Kind == format.PropertyChoice || p.Kind == format.PropertyBool {\n", + "\t\tif p.Kind == format.PropertyChoice {\n", + "TestEveryDialectTheTableDeclaresIsInTheSet", + ), + ( + "a type allowed and denied at once builds a set holding one file to " + "accept and another to turn away, both of them that type", + "internal/preset/uploadset.go", + "\tif err := listsAgree(s.allowed, s.denied); err != nil {\n\t\treturn s, err\n\t}\n", + "\t_ = listsAgree\n", + "TestAnExtensionCannotBeAllowedAndDeniedAtOnce", + ), + ( + "a denied file is named from the format id rather than from the " + "extension the registry declares, so targz becomes denied.targz", + "internal/preset/uploadfiles.go", + "\t\t\tname: \"denied\" + entry.extension(), size: sampleFor(entry.desc),\n", + "\t\t\tname: \"denied.\" + entry.ext, size: sampleFor(entry.desc),\n", + "TestADeniedFileIsNamedWithTheExtensionTheRegistryDeclares", + ), + ( + "the table preset promises a range of rows the spreadsheet does not " + "take, so the field and the refusal describe a set the run will not build", + "internal/preset/tabularimport.go", + "\tsheetRowsMax = 200000\n", + "\tsheetRowsMax = 100000\n", + "TestTheTabularPresetTakesTheRangesTheSheetDeclares", + ), + ( + "a combination the format refuses stops being left out of the encoding " + "set, so the whole set is refused over a cell nobody asked for", + "internal/preset/textencoding.go", + "\tif errors.As(err, &bad) {\n", + "\tif false && errors.As(err, &bad) {\n", + "TestTheEncodingSetHoldsEveryCombinationThisBuildCanWrite", + ), + # --- the window says what a finished run left, and offers the way to it + # (2026-09-23, the first contact analysis) --- + ( + "the window stops naming the manifest a finished run wrote", + "internal/gui/window/run.go", + "outcome := text.SaidWithManifest(outcomeText(res, runErr), manifestNameOf(saved.Manifest))", + "outcome := outcomeText(res, runErr)", + "TestAFinishedRunNamesTheManifestAndOffersToOpenIt", + ), + ( + "a control with no setting behind it goes on the form with no name over it", + "internal/gui/parts/fields.go", + # Repointed 2026-09-23, when a named control became a cell of the grid. + "\treturn cellOf(FieldStack(headingRow(label, detail, false), control))\n", + "\treturn cellOf(control)\n", + "TestEveryControlOnTheFormStandsUnderAName", + ), + ( + "the batch screen opens with no size, so Generate refuses on a form nobody typed into", + "internal/gui/window/recipe.go", + "\tr.batches[0].size.SetText(startingSize)\n", + '\tr.batches[0].size.SetText("")\n', + "TestEveryWorkScreenOpensReadyToRun", + ), + ( + "a batch added to the screen arrives carrying the first one's name", + "internal/gui/window/recipe.go", + "\tr.batches[len(r.batches)-1].formatPick.SetSelected(format.IDs()[0])\n", + "\tr.batches[len(r.batches)-1].formatPick.SetSelected(format.IDs()[0])\n\tr.batches[len(r.batches)-1].id.SetText(startingBatchName)\n", + "TestOnlyTheFirstBatchArrivesFilledIn", + ), + ( + "the About screen stops saying what to do with the program", + "internal/gui/window/about.go", + "\t\tparts.Section(text.SectionHowToUse(), parts.Bullets(text.HowToUseSteps())),\n", + "", + "TestTheAboutScreenSaysHowToUseTheProgram", + ), + # --- the palette comes from a rule, and the window can show it (#124, + # written down 2026-09-23 - the three guards landed with their + # mutations run by hand and never entered here, so the gate that asks + # whether every guard is proven was red on this machine and green in + # CI, where tools/ is not cloned) --- + ( + "the panel is four bytes off the rung the rule gives it", + "internal/gui/parts/theme.go", + "ColorNamePanel: hex(0x2B, 0x2B, 0x2F),", + "ColorNamePanel: hex(0x2F, 0x2F, 0x33),", + "TestThePaletteIsTheOneTheRuleProduces", + ), + ( + "the menu drops below the face of a button, so the thing that floats is under it", + "internal/gui/parts/theme.go", + "theme.ColorNameMenuBackground: hex(0x57, 0x57, 0x5F),", + "theme.ColorNameMenuBackground: hex(0x44, 0x44, 0x4A),", + "TestTheLadderClimbsInEvenSteps", + ), + ( + "the preview offers the manifest of the run it did not make", + "internal/gui/window/run.go", + "\tsaid := r.line.said(exactly(planned), opt.OutDir) + text.AndNothingWrittenYet()\n", + "\tr.offer.theManifest(engine.ManifestPath(opt))\n\tsaid := r.line.said(exactly(planned), opt.OutDir) + text.AndNothingWrittenYet()\n", + "TestAPreviewNamesNoManifestAndOffersNone", + ), + ( + "a colour joins the palette with no place in the order the page reads it by", + "internal/gui/parts/theme.go", + "\tdarkColours = map[fyne.ThemeColorName]color.Color{\n", + "\tdarkColours = map[fyne.ThemeColorName]color.Color{\n\t\ttheme.ColorNameScrollBar: hex(0x48, 0x48, 0x4E),\n", + "TestThePalettePageShowsEveryColourAndHowToReadIt", + ), + ( + "the steps drop one of the outcomes the manifest can record", + "internal/gui/text/screens.go", + "accept it, reject it or sanitize it - or records the outcome as unspecified", + "accept it or reject it - or records the outcome as unspecified", + "TestTheStepsNameEveryOutcomeTheManifestCanRecord", + ), + # --- the form in columns, groups that say what they are, and a refusal + # that offers its own fix (2026-09-23, the layout prototype) --- + ( + "every field takes a whole row of the form again", + "internal/gui/parts/grid.go", + "func spanOf(o fyne.CanvasObject, column float32) int {\n", + "func spanOf(o fyne.CanvasObject, column float32) int {\n\treturn GridColumns\n", + "TestShortFieldsShareARowAndTakeOneColumnEach", + ), + ( + "the button under a size refusal says the smallest size and puts nothing in the box", + "internal/gui/parts/fixes.go", + "func() { box.SetText(value) }", + "func() { _, _ = box, value }", + "TestASizeBelowTheMinimumOffersTheSmallestSize", + ), + # --- the list of formats grouped by kind, with a filter (2026-09-23, + # docs/FORMAT-MENU-2026-09-23.md) --- + ( + "a heading counts every format the filter kept rather than the ones under it", + "internal/gui/parts/narrow.go", + "text.ListHeadingCount(h, len(groups[h]))", + "text.ListHeadingCount(h, len(kept))", + "TestTheFormatListStandsUnderAHeadingForEachKind", + ), + ( + "a heading is matched anywhere in a word, so one letter keeps nearly every kind", + "internal/gui/parts/narrow.go", + "\t\tif strings.HasPrefix(word, want) {\n", + "\t\tif strings.Contains(word, want) {\n", + "TestTypingIntoTheFormatListNarrowsItAndLandsOnWhatStartsWithIt", + ), + ( + "the arrows stop on a heading as if it were a value", + "internal/gui/parts/narrow.go", + "\t\tif entries[at].kind == entryValue {\n\t\t\treturn at\n", + "\t\tif entries[at].kind != entryNotice {\n\t\t\treturn at\n", + "TestTheArrowsInTheFormatListStepOverTheHeadings", + ), + ( + "a letter typed at the shut format menu walks the values again instead of opening the filter", + "internal/gui/parts/ring.go", + "\tif c.Filtered {\n\t\tc.Tapped(nil)\n", + "\tif false && c.Filtered {\n\t\tc.Tapped(nil)\n", + "TestTypingAtTheShutFormatMenuOpensItsFilter", + ), + ( + "nothing of a value that matched the filter is drawn in bold", + "internal/gui/parts/narrow.go", + "e.from, e.to = at, at+len(want)", + "e.from, e.to = at, at", + "TestThePartOfAValueThatMatchedIsDrawnInBold", + ), + ( + "the shut format menu draws no picture of its value", + "internal/gui/parts/menulook.go", + "\tm.kind.Resource = m.menu.KindOf(m.menu.Selected)\n", + "\tm.kind.Resource = nil\n", + "TestTheShutFormatMenuDrawsThePictureOfItsValue", + ), + # --- after the review of #126 (2026-09-23, docs/REVIEW-126-2026-09-23.md) --- + ( + "the smallest-size button comes back under a limit, where it leads to the same refusal", + "internal/gui/parts/fixes.go", + "parsed != nil || held != below.Requested {", + "parsed != nil || held < 0 {", + "TestALimitBelowTheMinimumOffersNoButtonThatLeadsBackToItself", + ), + ( + "the bar may be narrowed until the rail covers the centred buttons again", + "internal/gui/parts/parts.go", + "size.Width = fyne.Max(size.Width, need)", + "size.Width = fyne.Max(size.Width, need*0)", + # Repointed 2026-09-24: on a screen the form decides the smallest + # window now, so only the bar laid out alone reaches this. + "TestABarAtItsNarrowestHoldsTheRailAndTheButtonsSideBySide", + ), + ( + "the picture called recipe-refused is taken of a recipe with nothing wrong in it again", + "internal/guard/screenpixels_test.go", + "\t\t{name: \"recipe-refused\", tab: text.TabRecipe(), set: func(t *testing.T, s scene) {\n\t\t\temptyTheFirstBatch(t, s.tab)\n", + "\t\t{name: \"recipe-refused\", tab: text.TabRecipe(), set: func(t *testing.T, s scene) {\n", + "TestEveryPictureNamedAfterARefusalShowsOne", + ), + ( + "a refusal about a manifest already in the directory goes back to the one line strip at the foot", + "internal/gui/window/runoffers.go", + "\t\treturn filepath.Dir(collision.Path)\n", + "\t\treturn filepath.Dir(collision.Path)[:0]\n", + "TestARefusalAboutWhatIsInTheDirectoryStandsUnderItAndOpensIt", + ), + ( + "a refusal is laid inside its field's column again", + "internal/gui/parts/grid.go", + "\tarea.Resize(fyne.NewSize(f.areaWidth, area.MinSize().Height))\n", + "\tarea.Resize(fyne.NewSize(size.Width, area.MinSize().Height))\n", + "TestARefusalStandsUnderItsRowAcrossIt", + ), + ( + "a menu's arrow goes back to the colour of text", + "internal/gui/parts/menulook.go", + "theme.NewColoredResource(theme.MenuDropDownIcon(), theme.ColorNamePrimary)", + "theme.NewColoredResource(theme.MenuDropDownIcon(), theme.ColorNameForeground)", + "TestAMenuShowsItsArrowInTheAccent", + ), + ( + "a refusal about a field in a folded section leaves the section shut", + "internal/gui/window/runrefuse.go", + "\t\t\tr.sections.openHolding(field.Control)\n", + "", + "TestAFoldedSectionOpensForARefusalAboutItsField", + ), + ( + "the Space that opens the format list is typed into its filter as an invisible space", + "internal/gui/parts/filterbox.go", + "\tif f.Text == \"\" && unicode.IsSpace(r) {\n", + "\tif false && f.Text == \"\" && unicode.IsSpace(r) {\n", + "TestTheSpaceThatOpensTheFormatListIsNotTypedIntoItsFilter", + ), + ( + "a control registered again wraps its callback again and reports under every address it had", + "internal/gui/parts/fields.go", + "\tif !to.point(target) {\n\t\treturn\n\t}\n", + "\tto.point(target)\n", + "TestAControlRegisteredAgainReportsOnceUnderItsLatestAddress", + ), + ( + "a size box counts into the caption it was first drawn with, not the one on the screen", + "internal/gui/parts/fields.go", + "func(value string, into *ByteCount) { into.show(value) })", + "func(value string, into *ByteCount) { count.show(value) })", + "TestAControlRegisteredAgainReportsOnceUnderItsLatestAddress", + ), + ( + "a control registered again keeps reporting under the address it had first", + "internal/gui/parts/fields.go", + "\tw.target = target\n", + "\tif !w.wired {\n\t\tw.target = target\n\t}\n", + "TestAControlRegisteredAgainReportsOnceUnderItsLatestAddress", + ), + ( + "the batch id box is also registered as the file names, one control under two addresses", + "internal/gui/window/recipe.go", + "r.tips.Say(text.DetailNameTemplate()), parts.Text(b.name)),", + "r.tips.Say(text.DetailNameTemplate()), parts.Text(b.id)),", + "TestNoControlIsRegisteredUnderTwoAddressesAtOnce", + ), + # --- the same work done again leaves the window as it was (2026-09-25, + # docs/GUI-LEAK-TEST-2026-09-25.md section 7): the guard asks about the + # class, so each of the three counts is shown to go red on a defect + # of its own --- + ( + "a control registered again wraps its callback again, so the same change reads the form more every round", + "internal/gui/parts/fields.go", + "\tif !to.point(target) {\n\t\treturn\n\t}\n", + "\tto.point(target)\n", + "TestTheSameWorkDoneAgainLeavesTheWindowAsItWas", + ), + ( + "the batch screen lays itself out again on top of the old panels instead of in their place", + "internal/gui/window/recipe.go", + "\tr.baseBox.RemoveAll()\n\tr.batchBox.RemoveAll()\n", + "\tr.baseBox.RemoveAll()\n", + "TestTheSameWorkDoneAgainLeavesTheWindowAsItWas", + ), + ( + "the worker of a run stays behind after reporting, waiting for a cancel nobody sends", + "internal/gui/window/run.go", + "\t\tclose(done)\n\t}()\n}\n\n// runFinished", + "\t\tclose(done)\n\t\t<-ctx.Done()\n\t}()\n}\n\n// runFinished", + "TestTheSameWorkDoneAgainLeavesTheWindowAsItWas", + ), + # --- the instructions beside the manifest (2026-09-25, + # docs/PRESET-INSTRUCTIONS-2026-09-25.md) - one break per rule --- + ( + "the file name preset stops passing its purposes on, so fifty files say nothing about themselves", + "internal/preset/filenamehandling.go", + "\t\t\texpected: expected, reason: reason, purpose: c.purpose,\n", + "\t\t\texpected: expected, reason: reason,\n", + "TestEveryFileOfEveryPresetSaysWhatItIsFor", + ), + ( + "a name reaches the instructions without its invisible characters escaped", + "internal/manifest/instructions.go", + "\"- %s - %s, %s.\", codeSpan(core.Shown(f.Name)),", + "\"- %s - %s, %s.\", codeSpan(f.Name),", + "TestTheInstructionsShowEveryNameAsItIsAndNothingElse", + ), + ( + "a name holding a backtick is fenced by one backtick and read as markup", + "internal/manifest/instructions.go", + "\tfence := strings.Repeat(\"`\", longest+1)\n", + "\tfence := strings.Repeat(\"`\", 1+0*longest)\n", + "TestTheInstructionsShowEveryNameAsItIsAndNothingElse", + ), + ( + "a purpose written across two lines breaks the list it stands in", + "internal/manifest/instructions.go", + "\treturn core.Shown(strings.Join(strings.Fields(s), \" \"))\n", + "\treturn core.Shown(s)\n", + "TestTheInstructionsShowEveryNameAsItIsAndNothingElse", + ), + ( + "a file that failed is folded in with the files of its target that were written", + "internal/manifest/instructions.go", + "\treturn a.TargetID != \"\" && a.TargetID == b.TargetID && whole(a) && whole(b)\n", + "\treturn a.TargetID != \"\" && a.TargetID == b.TargetID && (whole(a) || !whole(b))\n", + "TestTheInstructionsFoldARunOfOneTargetIntoOneEntry", + ), + ( + "verify reports the instructions beside the manifest as a file nobody asked for", + "internal/audit/audit.go", + "\t\tif seen[p] || filepath.Base(p) == skip || (m.Run.Instructions != \"\" && filepath.Base(p) == m.Run.Instructions) {\n", + "\t\tif seen[p] || filepath.Base(p) == skip {\n", + "TestTheInstructionsGoWhereTheManifestGoes", + ), + ( + "cleanup --with-manifest takes the manifest and leaves its instructions behind", + "internal/cli/cleanup.go", + "\treturn append(record, full), \"\"\n", + "\treturn record, \"\"\n", + "TestTheInstructionsGoWhereTheManifestGoes", + ), + ( + "a run nobody explained writes instructions that can only say nothing", + "internal/manifest/instructions.go", + "\t\tif f.Purpose != \"\" {\n\t\t\treturn true\n", + "\t\tif f.Purpose != \"\" || true {\n\t\t\treturn true\n", + "TestARunWithNoPurposeWritesNoInstructions", + ), + ( + "instructions an earlier run left are not asked about before the first file", + "internal/engine/preflight.go", + "\tif explainedFiles(files) {\n", + "\tif false && explainedFiles(files) {\n", + "TestTheNameOfTheInstructionsIsHeldBeforeAnyFile", + ), + ( + "a target may take the name of the instructions beside the manifest", + "internal/engine/engine.go", + "\t\tpl.names[collisionKey(name)] = nameOwner{name: name, instructions: true}\n", + "\t\t_ = nameOwner{name: name, instructions: true}\n", + "TestTheNameOfTheInstructionsIsHeldBeforeAnyFile", + ), + ( + "a manifest naming its instructions outside the directory is read and acted on", + "internal/manifest/manifest.go", + "\tif name := m.Run.Instructions; name != \"\" && !isInstructionsName(name) {\n", + "\tif name := m.Run.Instructions; false && name != \"\" && !isInstructionsName(name) {\n", + "TestAManifestCannotSendCleanupAfterAFileOfSomebodyElses", + ), + ( + "instructions that could not be written are named in the manifest all the same", + "internal/engine/record.go", + "\t\t\trec.Missed = &InstructionsError{Path: path, Err: err}\n\t\t} else {\n", + "\t\t\t_ = err\n\t\t\trec.Instructions = path\n\t\t\tm.Run.Instructions = filepath.Base(path)\n\t\t} else {\n", + "TestInstructionsThatCannotBeWrittenAreSaidAndNotPretended", + ), + ( + "the window writes instructions and offers no way to open them", + "internal/gui/window/run.go", + "\tr.offer.instructions.show(saved.Instructions)\n", + "", + "TestTheWindowOpensTheInstructionsItsRunWrote", + ), + ( + "the smallest size of every format is worked out again at every expansion of the minimal set", + "internal/format/registry.go", + "\tsmallestMu.Unlock()\n\tif found {\n\t\treturn known\n", + "\tsmallestMu.Unlock()\n\tif found && false {\n\t\treturn known\n", + "TestTheMinimalSetIsWorkedOutOnceAndNotAtEveryExpansion", + ), + ( + "a sentence drawn ink tight goes back under a theme override, a scope and a font set of its own", + "internal/gui/parts/words.go", + "\treturn container.New(inkTightLayout{}, o)\n", + "\treturn container.NewThemeOverride(o, Theme())\n", + "TestNoScreenStandsInAThemeOverride", + ), + ( + "the live check reads the form a second time to mark the box", + "internal/gui/window/runrefuse.go", + "\tfor _, one := range spread(err) {\n\t\tvar about interface{ AboutSetting() string }\n", + "\t_, _, err = r.settle()\n\tfor _, one := range spread(err) {\n\t\tvar about interface{ AboutSetting() string }\n", + "TestOneChangeOfABoxReadsTheFormOnce", + ), + ( + "the live check stops putting what the form comes to on the line", + "internal/gui/window/runrefuse.go", + "\tlineFrom(r, targets, opt, err)\n\t// Only this box", + "\t_, _ = targets, opt\n\t// Only this box", + "TestTheLineSaysWhatTheFormComesTo", + ), + ( + "the batch screen reads its form without telling the host", + "internal/gui/window/recipe.go", + "\tr.runner.settle = watchedSettle(host, r.runner, r.settle)\n", + "\tr.runner.settle = r.settle\n", + "TestOneChangeOfABoxReadsTheFormOnce", + ), + ( + "the preset screen expands the preset whatever it remembers", + "internal/gui/window/preset.go", + "\tif l.held && l.id == id && maps.Equal(l.given, given) {\n", + "\tif false && l.held && l.id == id && maps.Equal(l.given, given) {\n", + "TestTypingWhatAPresetIsNotGivenDoesNotExpandItAgain", + ), + ( + "the preset screen keeps its expansion whatever the preset is given", + "internal/gui/window/preset.go", + "\tif l.held && l.id == id && maps.Equal(l.given, given) {\n", + "\tif l.held && l.id == id {\n", + "TestAChangedPresetValueIsExpandedAgain", + ), + ( + "the preset screen keeps its expansion whichever preset is chosen", + "internal/gui/window/preset.go", + "\tif l.held && l.id == id && maps.Equal(l.given, given) {\n", + "\tif l.held && maps.Equal(l.given, given) {\n", + "TestAChangedPresetValueIsExpandedAgain", + ), + ( + "the preset screen drops the notes of an expansion it remembered", + "internal/gui/window/preset.go", + "\t\treturn l.got, l.notes, l.err\n", + "\t\treturn l.got, nil, l.err\n", + "TestThePresetScreenSaysWhichNumbersWereOurs", + ), + ( + "the upload set works out a format's smallest size for every file about a name", + "internal/preset/build.go", + "if floor := format.SmallestWithLabel(desc); floor > sample {", + "if floor := desc.SmallestAccepted(format.Request{Label: true}); floor > sample {", + "TestANewUploadLimitDoesNotAskTheSameQuestionsAgain", + ), + ( + "the upload set plans the same picture under every name it holds", + "internal/preset/uploadset.go", + "\tif asked[key] {\n\t\treturn false\n\t}\n", + "\tif false && asked[key] {\n\t\treturn false\n\t}\n", + "TestANewUploadLimitDoesNotAskTheSameQuestionsAgain", + ), + ( + "a file asked at its floor has the floor worked out every time it is asked", + "internal/preset/build.go", + "return format.SmallestRemembered(f.desc, format.Request{Label: true, Properties: f.props})", + "return f.desc.SmallestAccepted(format.Request{Label: true, Properties: f.props})", + "TestANewRowCountWorksTheSheetsSmallestSizeOutOnce", + ), + ( + "the key of a request leaves out its seed", + "internal/format/registry.go", + "r.SizeFromContents, r.Label, r.Seed)", + "r.SizeFromContents, r.Label, uint64(0))", + "TestEveryFieldOfARequestIsInItsKey", + ), + ( + "the key of a request stops quoting the values of its settings", + "internal/format/registry.go", + "\t\tfmt.Fprintf(&b, \" %q=%q\", name, r.Properties[name])\n", + "\t\tfmt.Fprintf(&b, \" %s=%s\", name, r.Properties[name])\n", + "TestEveryFieldOfARequestIsInItsKey", + ), + ( + "a request with contents is keyed as if it had none", + "internal/format/registry.go", + "\tif len(r.Contains) > 0 {\n\t\treturn \"\", false\n\t}\n", + "\tif false && len(r.Contains) > 0 {\n\t\treturn \"\", false\n\t}\n", + "TestEveryFieldOfARequestIsInItsKey", + ), + ( + "laying the batch screen out says the line again, so a menu or a switch reads the form twice", + "internal/gui/window/recipe.go", + "\t// reading expanded it (docs/GUI-MEMORY-2026-09-23.md section 4j).\n}\n", + "\t// reading expanded it (docs/GUI-MEMORY-2026-09-23.md section 4j).\n\tr.runner.refreshLine()\n}\n", + "TestAChangeThatLaysTheBatchScreenOutAgainReadsTheFormOnce", + ), + ( + "a press that lays the batch screen out leaves the line describing the old form", + "internal/gui/window/recipe.go", + "func afterAPress(r *Recipe) {\n\tr.rebuild()\n\tr.runner.refreshLine()\n}\n", + "func afterAPress(r *Recipe) {\n\tr.rebuild()\n}\n", + "TestAChangeThatLaysTheBatchScreenOutAgainReadsTheFormOnce", + ), + ( + "the base switched off with no batch left brings the batch back through the press, which says the line a second time", + "internal/gui/window/recipebase.go", + "\t\t\tnewBatchAtTheEnd(r)\n\t\t}\n\t\tr.rebuild()\n", + "\t\t\tr.addBatch()\n\t\t}\n\t\tr.rebuild()\n", + "TestAChangeThatLaysTheBatchScreenOutAgainReadsTheFormOnce", + ), + ( + "something done while a release waits does not call it off, so memory goes back in the middle of work", + "internal/gui/window/tidy.go", + "func (t *tidy) touch() {\n\tt.Stop()\n\tt.after(", + "func (t *tidy) touch() {\n\tt.after(", + "TestSomethingDoneDuringTheWaitStartsTheQuietOver", + ), + ( + "memory is given back while a run owns the window", + "internal/gui/window/tidy.go", + "func (t *tidy) release() {\n\tif t.busy() {\n\t\tt.touch()\n\t\treturn\n\t}\n", + "func (t *tidy) release() {\n", + "TestNoMemoryIsGivenBackWhileWorkIsGoing", + ), + ( + "the quiet ends before the toolkit's renderers expire", + "internal/gui/window/tidy.go", + "const tidyAfterQuiet = 70 * time.Second", + "const tidyAfterQuiet = 50 * time.Second", + "TestTheWindowGivesMemoryBackOnceItHasBeenLeftAlone", + ), + ( + "memory goes back sooner than the toolkit's next clean after the frame", + "internal/gui/window/tidy.go", + "const tidyAfterFrame = 12 * time.Second", + "const tidyAfterFrame = 2 * time.Second", + "TestTheWindowGivesMemoryBackOnceItHasBeenLeftAlone", + ), + ( + "memory is given back on the window's thread, which once took 2491 ms", + "internal/gui/window/tidy.go", + "\tgo debug.FreeOSMemory()\n", + "\tdebug.FreeOSMemory()\n", + "TestConcurrencyStaysWhereItWasPutOnPurpose", + ), + ( + "a reading of the form does not tell the wait for quiet, so the window never gives memory back", + "internal/gui/window/open.go", + "\t\tif r.touched != nil {\n\t\t\tr.touched()\n\t\t}\n", + "", + "TestTheWindowGivesMemoryBackOnceItHasBeenLeftAlone", + ), + ( + "the concurrency scan stops seeing a timer's callback", + "internal/guard/concurrency_test.go", + "\tcase id.Name == \"time\" && node.Sel.Name == \"AfterFunc\":\n", + "\tcase false && id.Name == \"time\" && node.Sel.Name == \"AfterFunc\":\n", + "TestTheConcurrencyScanSeesEachKindItLooksFor", + ), + ( + "the window's clock stands outside the list of concurrent files", + "internal/guard/concurrency_test.go", + "\t\"internal/gui/run_cgo.go\": ", + "\t\"internal/gui/run_cgo_elsewhere.go\": ", + "TestConcurrencyStaysWhereItWasPutOnPurpose", + ), + ( + "a wait called off on its way is not asked whether it still belongs, so the quiet somebody broke gives memory back", + "internal/gui/window/tidy.go", + "\t\tif t.wait != mine {\n", + "\t\tif t.wait < mine {\n", + "TestAWaitCalledOffOnItsWayDoesNothingWhenItArrives", + ), + ( + "calling the wait off leaves a call already on its way thinking it is still the one waited for", + "internal/gui/window/tidy.go", + "func (t *tidy) Stop() {\n\tt.wait++\n", + "func (t *tidy) Stop() {\n", + "TestAWaitCalledOffOnItsWayDoesNothingWhenItArrives", + ), + ( + "a declared concurrent file that is gone is not reported", + "internal/guard/concurrency_test.go", + "filepath.FromSlash(rel))); err != nil {\n", + "filepath.FromSlash(rel))); err != nil && false {\n", + "TestADeclarationWhoseFileIsGoneIsReported", + ), + ( + "the formats table puts the identifier where the name goes", + "internal/cli/formats.go", + "d.ID, named, d.Name, d.Fidelity", + "d.ID, named, d.ID, d.Fidelity", + "TestEveryFormatIsNamedWhereverTheCommandLineListsIt", + ), + ( + "the card for one format stops giving its name", + "internal/cli/formats.go", + "\tfmt.Fprintf(out, \" name %s\\n\", d.Name)\n", + "\t_ = d.Name\n", + "TestEveryFormatIsNamedWhereverTheCommandLineListsIt", + ), + ( + "the machine readable list leaves the name out", + "internal/cli/formats.go", + "\t\tName: d.Name,\n", + "\t\tName: \"\",\n", + "TestEveryFormatIsNamedWhereverTheCommandLineListsIt", + ), + ( + "a format is registered with no name", + "internal/format/jxl/jxl.go", + "\t\tName: \"JPEG XL\",\n", + "\t\tName: \"\",\n", + "TestEveryFormatDeclaresTheFullSet", + ), + ( + "a format name carries a semicolon the prose rule refuses", + "internal/format/targz/targz.go", + "\t\tName: \"tar + gzip\",\n", + "\t\tName: \"tar; gzip\",\n", + "TestEveryFormatDeclaresTheFullSet", + ), + ( + "a format name carries a letter the command line cannot print", + "internal/format/bmp/bmp.go", + "\t\tName: \"Windows Bitmap\",\n", + "\t\tName: \"Windows Bitmäp\",\n", + "TestEveryFormatDeclaresTheFullSet", + ), + ( + "a format name starts with a space", + "internal/format/webp/webp.go", + "\t\tName: \"WebP\",\n", + "\t\tName: \" WebP\",\n", + "TestEveryFormatDeclaresTheFullSet", + ), + ( + "the open list of formats stops naming its values", + "internal/gui/parts/ring.go", + "\t\tlist.NameEach(c.NameOf)\n", + "\t\t_ = c.NameOf\n", + "TestTheFormatListNamesEveryFormatBesideItsIdentifier", + ), + ( + "each name starts right after its own value, so the names are no column", + "internal/gui/parts/listrow.go", + "\tat := r.row.column + listNameGap\n", + "\tat := r.value().width() + listNameGap\n", + "TestTheFormatListNamesEveryFormatBesideItsIdentifier", + ), + ( + "a name is drawn in the ink of the value beside it", + "internal/gui/parts/listrow.go", + "\tr.name.whole.Color = PaletteColour(ColorNameLabel, theme.VariantDark)\n", + "\tr.name.whole.Color = Theme().Color(theme.ColorNameForeground, theme.VariantDark)\n", + "TestTheFormatListNamesEveryFormatBesideItsIdentifier", + ), + ( + "the list of formats opens at the width of its box again", + "internal/gui/parts/ring.go", + "ColumnForList(surface.Size().Width, at.X, c.Size().Width, listWidth(c, c.Size().Width))", + "ColumnForList(surface.Size().Width, at.X, c.Size().Width, c.Size().Width)", + "TestTheFormatListIsWiderThanItsBoxAndCutsNoName", + ), + ( + "the width of the list leaves the names out, so they are cut off", + "internal/gui/parts/ring.go", + "RowWidthFor(column+listNameGap+name, c.KindOf != nil)", + "RowWidthFor(column+0*name, c.KindOf != nil)", + "TestTheFormatListIsWiderThanItsBoxAndCutsNoName", + ), + ( + "a list wider than the room beside its box runs past the window's edge", + "internal/gui/parts/ring.go", + "\t\tleft = fyne.Max(listEdgeGap, end-width)\n", + "\t\tleft = boxLeft\n", + "TestAListWiderThanTheRoomBesideItsBoxMovesLeft", + ), + ( + "a list no wider than its box is moved off it near the edge", + "internal/gui/parts/ring.go", + "width > boxWidth && left+width > end {", + "left+width > end {", + "TestAListWiderThanTheRoomBesideItsBoxMovesLeft", + ), + ( + "a list wider than the whole window is not cut to it", + "internal/gui/parts/ring.go", + "width > room && width > boxWidth {", + "false && width > room && width > boxWidth {", + "TestAListWiderThanTheRoomBesideItsBoxMovesLeft", + ), + ( + "the filter stops reading the names", + "internal/gui/parts/narrow.go", + "(by.nameOf != nil && aWordStartsWith(by.nameOf(v), want))", + "(by.nameOf != nil && false)", + "TestTheFilterFindsAFormatByAWordOfItsName", + ), + ( + "words part only at spaces again, so a word after a bracket or a hyphen is not found", + "internal/gui/parts/narrow.go", + "func wordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }", + "func wordRune(r rune) bool { return !unicode.IsSpace(r) }", + "TestTheFilterFindsAFormatByAWordOfItsName", + ), + ( + "every letter of a name counts as the start of a word", + "internal/gui/parts/narrow.go", + "; at > 0 && wordRune(before) {", + "; false && at > 0 && wordRune(before) {", + "TestTheFilterFindsAFormatByAWordOfItsName", + ), + ( + "the sentence saying nothing matched is drawn as a heading again", + "internal/gui/parts/listrow.go", + "\tcase r.row.notice:\n", + "\tcase false && r.row.notice:\n", + "TestTheListSaysWhatToDoWhenNothingMatches", + ), + ( + "a quiet button switched off goes back to the brighter disabled ink", + "internal/gui/parts/button.go", + "\t\t\tborder.ink = theme.ColorNameInputBorder\n", + "\t\t\tborder.ink = theme.ColorNameDisabled\n", + "TestAControlSwitchedOffIsNeverBrighterThanAtRest", + ), + ( + "a switch switched off goes back to the bright disabled edge", + "internal/gui/parts/toggle.go", + "\t\tr.square.StrokeColor = PaletteColour(theme.ColorNameSeparator, dark)\n", + "\t\tr.square.StrokeColor = PaletteColour(theme.ColorNameDisabled, dark)\n", + "TestAControlSwitchedOffIsNeverBrighterThanAtRest", + ), + ( + "a box switched off keeps the toolkit's bright border", + "internal/gui/parts/entry.go", + "\tif !q.entry.Disabled() {\n", + "\tif true {\n", + "TestAControlSwitchedOffIsNeverBrighterThanAtRest", + ), + ( + "a ticked switch that is off hides its tick again", + "internal/gui/parts/toggle.go", + "\tcase off && r.toggle.Checked:\n", + "\tcase false && off && r.toggle.Checked:\n", + "TestASwitchThatIsOffStillShowsItsValue", + ), + ( + "a button's edge is stroked across its bounds again, a pixel taller than the box beside it", + "internal/gui/parts/button.go", + "\tif edge > 0 {\n", + "\tif false && edge > 0 {\n", + "TestAButtonBesideABoxIsDrawnAsTallAsTheBox", + ), + ( + "the heart follows the words' ink instead of red", + "internal/gui/parts/heart.go", + "\tb.iconInk = theme.ColorNameError\n", + "\tb.iconInk = theme.ColorNamePlaceHolder\n", + "TestDonateDrawsARedHeartThatGoesOutWhenOff", + ), + ( + "a button switched off keeps its own inks", + "internal/gui/parts/button.go", + "\tif r.state() != stateDisabled {\n", + "\tif true {\n", + "TestDonateDrawsARedHeartThatGoesOutWhenOff", + ), + ( + "the rail stands on the form's edge by its invisible box rather than by its words", + "internal/gui/parts/parts.go", + "hungOut{by: BarButtonInsetX}", + "hungOut{by: 0}", + "TestTheBarsRailStandsOnTheFormsLeftEdge", + ), + ( + "the run buttons stay centred however narrow, running under the rail", + "internal/gui/parts/parts.go", + "\tkeep.clearLeft = room\n", + "\tkeep.clearLeft = 0\n", + "TestTheBarsButtonsClearItsRailInTheNarrowestWindow", + ), + ( + "a group's rail goes back to the primary colour", + "internal/gui/parts/folding.go", + "\trail := canvas.NewRectangle(PaletteColour(ColorNameLabel, theme.VariantDark))\n", + "\trail := canvas.NewRectangle(PaletteColour(theme.ColorNamePrimary, theme.VariantDark))\n", + "TestAGroupOfSettingsIsFramedWithTheOneNeutralRail", + ), + ( + "a refusal starts at its own field's edge again, and two of them step down", + "internal/gui/parts/grid.go", + "\t\tcell.areaLeft = -x\n", + "\t\tcell.areaLeft = 0\n", + "TestARefusalStandsUnderItsRowAcrossIt", + ), + ( + "a list opened downward keeps the height of the whole list while it is filtered", + "internal/gui/parts/ring.go", + "\t\tlist.resize = pop.Resize\n", + "\t\t_ = pop.Resize\n", + "TestAListOpenedDownwardShrinksToWhatTheFilterLeft", + ), + ( + "a list opened upward shrinks too, moving its filter under the hands", + "internal/gui/parts/ring.go", + "\tif top >= at.Y+c.Size().Height {\n", + "\tif true {\n", + "TestAListOpenedUpwardKeepsOneHeight", + ), + ( + "the card an open list floats on loses its edge", + "internal/gui/parts/parts.go", + "\tface.StrokeWidth = edgeWidth\n", + "\tface.StrokeWidth = 0\n", + "TestAnOpenListIsToldFromTheFormBehindIt", + ), + ( + "the card an explanation floats on casts no shade", + "internal/gui/parts/parts.go", + "\treturn []fyne.CanvasObject{container.New(shifted{dy: TipShadowDrop}, shade), face}\n", + "\treturn []fyne.CanvasObject{face}\n", + "TestTheExplanationFloatsOnTheSurfaceAnOpenListDoes", + ), + ( + "a rail that grows in place leaves the run buttons under it again", + "internal/gui/parts/parts.go", + "\tif moved {\n\t\trow.Layout.Layout(row.Objects, row.Size())", + "\tif moved && false {\n\t\trow.Layout.Layout(row.Objects, row.Size())", + "TestARailThatGrowsInPlaceMovesTheRunButtonsOnWithIt", + ), + ( + "the palette's sentence for menuBackground runs past its section again", + "internal/gui/catalogue/palette.go", + "\"the toolkit's own menus - the one a right press opens in a box. Our lists float on a card\"", + "\"what the toolkit's own menus float on - the one a box opens on a right press. This window's lists float on a card since 2026-09-24\"", + "TestNoWordsInTheCatalogueRunPastTheEdgeOfItsSections", + ), + ( + "a long name's temporary form is the plain join again, too long for any file system", + "internal/core/sibling.go", + "if len(name)+len(suffix) <= MaxNameBytes {", + "if true {", + "TestANameAsLongAsEverySystemStoresIsWritten", + ), + ( + "two long names that begin alike share one temporary name again", + "internal/core/sibling.go", + "sum := sha256.Sum256([]byte(name))", + "sum := sha256.Sum256(nil)", + "TestTwoLongNamesThatBeginAlikeGetTwoSiblings", + ), + ( + "a long name is cut part way through a character again", + "internal/core/sibling.go", + "for n > 0 && !utf8.RuneStart(s[n]) {", + "for n > 0 && !utf8.RuneStart(s[n]) && false {", + "TestTwoLongNamesThatBeginAlikeGetTwoSiblings", + ), + ( + "the marker of a shortened leftover is no longer last, so verify calls it extra", + "internal/core/sibling.go", + "return cutToWholeCharacters(name, MaxNameBytes-len(suffix)-len(tag)) + tag + suffix", + "return cutToWholeCharacters(name, MaxNameBytes-len(suffix)-len(tag)) + suffix + tag", + "TestALeftoverUnderAShortenedNameIsNamedForWhatItIs", + ), + ( + "the engine joins the temporary name the plain way again", + "internal/engine/preflight.go", + 'return core.SiblingName(name, fmt.Sprintf("%s%d", core.PartialMarker, os.Getpid()))', + 'return name + fmt.Sprintf("%s%d", core.PartialMarker, os.Getpid())', + "TestANameAsLongAsEverySystemStoresIsWritten", + ), + ( + "a name longer than every system stores is written again where it can be", + "internal/engine/filename.go", + "case len(name) > core.MaxNameBytes:", + "case len(name) > core.MaxNameBytes && false:", + "TestANameLongerThanEverySystemStoresIsRefusedBeforeAnythingIsWritten", + ), + ( + "a name exactly as long as every system stores is refused a byte too early", + "internal/engine/filename.go", + "case len(name) > core.MaxNameBytes:", + "case len(name) >= core.MaxNameBytes:", + "TestANameLongerThanEverySystemStoresIsRefusedBeforeAnythingIsWritten", + ), + ( + "formatting a recipe in place joins its temporary name the plain way again", + "internal/core/replace.go", + "tmp := SiblingPath(path, writingSuffix)", + "tmp := path + writingSuffix", + "TestARecipeNamedAsLongAsEverySystemStoresCanBeReplacedInPlace", + ), + ( + # O243, measured 2026-09-24: TrimSpace took an ideographic space off + # the front of a name and the file was written under another name. + "a recipe value loses Unicode white space at its ends again", + "internal/recipe/scalar.go", + "\tt := strings.Trim(string(b), yamlBlank)", + "\tt := strings.TrimSpace(string(b))", + "TestAUnicodeSpaceAtEitherEndOfANameIsKept", + ), + ( + # O244, measured 2026-09-24: an ejected recipe carried a right to + # left override and a line separator raw, and PyYAML refused it. + "a composed value holding a character nobody can see is written raw again", + "internal/recipe/compose.go", + "\treturn escapedText(value)", + "\treturn value", + "TestACharacterNobodyCanSeeIsWrittenAsAnEscapeAndReadBackAsItself", + ), + ( + # The quotes alone are not the repair. Single quotes carry the + # character raw, which is the document PyYAML refused. + "the quoted form keeps the character raw inside the quotes", + "internal/recipe/compose.go", + "\treturn []byte(strconv.Quote(string(e))), nil", + "\treturn []byte(\"'\" + string(e) + \"'\"), nil", + "TestACharacterNobodyCanSeeIsWrittenAsAnEscapeAndReadBackAsItself", + ), + ( + # O241, measured 2026-09-24: verify drew "photo", a right to left + # override, "gpj.txt" as phototxt.jpg. + "the primitive writes a character nobody can see as it is", + "internal/core/unseen.go", + "\t\tcase !strconv.IsPrint(r):\n", + "\t\tcase false:\n", + "TestVerifyShowsANameNobodyCanReadAsAnEscape", + ), + ( + "verify prints a difference without showing its names", + "internal/audit/audit.go", + "\td.Path, d.Want, d.Got = core.Shown(d.Path), core.Shown(d.Want), core.Shown(d.Got)\n", + "", + "TestVerifyShowsANameNobodyCanReadAsAnEscape", + ), + ( + "cleanup previews a removal without showing the name", + "internal/cli/cleanup.go", + "\" remove %s\\n\", core.Shown(c.Path))", + "\" remove %s\\n\", c.Path)", + "TestCleanupShowsANameNobodyCanReadAsAnEscape", + ), + ( + "cleanup previews a file it keeps without showing the name", + "internal/cli/cleanup.go", + "\" keep %s - %s\\n\", core.Shown(c.Path), skipNote(c, force))", + "\" keep %s - %s\\n\", c.Path, skipNote(c, force))", + "TestCleanupShowsANameNobodyCanReadAsAnEscape", + ), + ( + "cleanup reports a file it kept without showing the name", + "internal/cli/cleanup.go", + "\"kept %s - %s\\n\", core.Shown(o.Path), core.Shown(o.Reason))", + "\"kept %s - %s\\n\", o.Path, core.Shown(o.Reason))", + "TestCleanupShowsANameNobodyCanReadAsAnEscape", + ), + ( + "a note about one file prints its name raw", + "internal/manifest/manifest.go", + "\t\treturn fmt.Sprintf(\"%s: %s\", core.Shown(g.first[0]), detail)", + "\t\treturn fmt.Sprintf(\"%s: %s\", g.first[0], detail)", + "TestANoteAboutAFileShowsANameNobodyCanReadAsAnEscape", + ), + ( + "a note about several files prints their names raw", + "internal/manifest/manifest.go", + "\tnamed := strings.Join(core.ShownEach(g.first), \", \")", + "\tnamed := strings.Join(g.first, \", \")", + "TestANoteAboutAFileShowsANameNobodyCanReadAsAnEscape", + ), + ( + "a refusal of two names differing in case prints them raw", + "internal/engine/names.go", + "which differ only in case. Most filesystems treat those as one file, so one would be written over the other and the manifest would describe both\",\n\t\t\towner.id, id, core.Shown(owner.name), core.Shown(name))", + "which differ only in case. Most filesystems treat those as one file, so one would be written over the other and the manifest would describe both\",\n\t\t\towner.id, id, owner.name, name)", + "TestARefusalShowsANameNobodyCanReadAsAnEscape", + ), + ( + "a refusal of a taken name prints the path raw", + "internal/engine/errors.go", + "remove the file first\",\n\t\tcore.Shown(e.Path))", + "remove the file first\",\n\t\te.Path)", + "TestARefusalShowsANameNobodyCanReadAsAnEscape", + ), + ( + "the files of a boundary set are listed raw", + "internal/cli/generate.go", + "\" %-26s %s\\n\", core.Shown(f.Name), ", + "\" %-26s %s\\n\", f.Name, ", + "TestTheLinesAboutARunShowANameNobodyCanReadAsAnEscape", + ), + ( + "the line naming a run's manifest prints the path raw", + "internal/cli/record.go", + "fmt.Fprintf(errOut, \"manifest: %s\\n\", core.Shown(path))", + "fmt.Fprintf(errOut, \"manifest: %s\\n\", path)", + "TestTheLinesAboutARunShowANameNobodyCanReadAsAnEscape", + ), + ( + "a note about another run lists its files raw", + "internal/cli/verify.go", + "\tnames = core.ShownEach(names)\n", + "", + "TestTheLinesAboutARunShowANameNobodyCanReadAsAnEscape", + ), + ( + "a note about another run names its record raw", + "internal/cli/verify.go", + "\t\t\tcore.Shown(name), core.Count(len(files), \"file\", \"files\"), belongs(len(files)), someOf(files))", + "\t\t\tname, core.Count(len(files), \"file\", \"files\"), belongs(len(files)), someOf(files))", + "TestTheLinesAboutARunShowANameNobodyCanReadAsAnEscape", + ), + ( + "a refusal of an output directory that is a file prints it raw", + "internal/engine/preflight.go", + "\"the output directory %s is a file, not a directory\", core.Shown(opt.OutDir))", + "\"the output directory %s is a file, not a directory\", opt.OutDir)", + "TestTheLinesAboutARunShowANameNobodyCanReadAsAnEscape", + ), + ( + "a refusal of a held directory prints it raw", + "internal/engine/errors.go", + "\t\tcore.Shown(e.Dir), core.Shown(e.Path))", + "\t\te.Dir, e.Path)", + "TestTheLinesAboutARunShowANameNobodyCanReadAsAnEscape", + ), + ( + # A right to left override typed into a comment of shipped code - the + # Trojan Source shape this guard is for. + "a character nobody can see gets into a tracked file", + "internal/core/unseen.go", + "// HoldsUnseen reports whether s holds a character a person reading it cannot", + "// HoldsUnseen reports whether s holds a character a person\u202e reading it cannot", + "TestNoTrackedFileCarriesACharacterNobodyCanSee", + ), + ( + "the detector stops recognising a character nobody can see", + "internal/guard/hiddencharacters_test.go", + "\tcase unseenHere(r):", + "\tcase false:", + "TestTheHiddenCharacterDetectorFindsWhatItIsFor", + ), + ( + "a preset applies a default other than the one it declares for --format", + "internal/preset/sizeboundaries.go", + "formatID := defaultFormat", + "formatID := \"txt\"", + "TestEveryFlagAPresetReadsDefaultsToOneValueOnBothSurfaces", + ), + ( + "the full width extension is written in plain letters", + "internal/preset/filenamehandling.go", + "\t\t\tr += 0xFF01 - '!'", + "\t\t\tr += 0", + "TestTheFileNamePresetAsksForEveryNameInEveryFormat", + ), + ( + "the decomposed names are written composed", + "internal/preset/filenamehandling.go", + "\t\tstem = norm.NFD.String(stem)", + "\t\tstem = norm.NFC.String(stem)", + "TestTheFileNamePresetAsksForEveryNameInEveryFormat", + ), + ( + "a name about length comes out a copy short", + "internal/preset/filenamehandling.go", + "\t\tcopies := (c.bytes - len(ext)) / len(c.fill)", + "\t\tcopies := (c.bytes-len(ext))/len(c.fill) - 1", + "TestTheFileNamePresetAsksForEveryNameInEveryFormat", + ), + ( + "a name that means something only as itself gets the format's extension", + "internal/preset/filenamehandling.go", + "\tcase noExtension:\n\t\tsuffix = \"\"", + "\tcase noExtension:\n\t\tsuffix = ext", + "TestTheFileNamePresetAsksForEveryNameInEveryFormat", + ), + ( + "the names a system must take stop being promised", + "internal/preset/filenamehandling.go", + "\t\treturn \"accept\", \"\"", + "\t\treturn \"unspecified\", \"filename_invalid\"", + "TestTheFileNamePresetPromisesOnlyWhatASystemMustDo", + ), + ( + # The one long name the preset asks for, 255 bytes, needs the short + # temporary name of O239 to be written at all. + "a name of 255 bytes is written under a temporary name too long again", + "internal/core/sibling.go", + "\tif len(name)+len(suffix) <= MaxNameBytes {", + "\tif true {", + "TestTheFileNamePresetWritesEveryNameByteForByte", + ), + ( + "the preset ignores a format that is named", + "internal/preset/filenamehandling.go", + "\t\tformatID = v\n", + "\t\tformatID = \"pdf\"\n", + "TestThePresetScreenWritesTheNamesTheCommandLineWrites", + ), + ( + # Provable only since a second preset reads --format with another + # default: with one, every default the window could take was right. + "the window opens a flag a preset reads on some other default", + "internal/preset/preset.go", + "\t\t\tdeclared.Default = p.ReadDefaults[name]", + "\t\t\tdeclared.Default = \"pdf\"", + "TestEveryFlagAPresetReadsDefaultsToOneValueOnBothSurfaces", + ), + ( + # Review of #138, 2026-09-25: a system error wrapped under our sentence + # repeated the output directory raw. + "a message of the command line is printed without escaping", + "internal/cli/errors.go", + "\treturn core.ShownText(inOurWords(err))", + "\treturn inOurWords(err)", + "TestTheLinesAboutARunShowANameNobodyCanReadAsAnEscape", + ), + ( + "verify names the directory raw in its mismatch line", + "internal/cli/verify.go", + "\"tfg: %s does not match %s - %s:\\n\", core.Shown(dir), core.Shown(path), ", + "\"tfg: %s does not match %s - %s:\\n\", dir, path, ", + "TestVerifyShowsANameNobodyCanReadAsAnEscape", + ), + ( + "cleanup names the directory raw in its preview", + "internal/cli/cleanup.go", + "core.Count(countRemovable(cands, force), \"file\", \"files\"), core.Shown(run.dir))", + "core.Count(countRemovable(cands, force), \"file\", \"files\"), run.dir)", + "TestCleanupShowsANameNobodyCanReadAsAnEscape", + ), + ( + "the window's refusal at the foot of the form is raw", + "internal/gui/window/runrefuse.go", + "\t\tloose = append(loose, core.ShownText(one.Error()))", + "\t\tloose = append(loose, one.Error())", + "TestTheWindowShowsADirectoryNobodyCanReadAsAnEscape", + ), + ( + # Review of #138: nothing asked the names whose point is an ASCII + # character at the edge, so trimming one would have passed. + "the leading space of a name is lost", + "internal/preset/filenamehandling.go", + "stem: \" leading space\",", + "stem: \"leading space\",", + "TestTheFileNamePresetAsksForEveryNameInEveryFormat", + ), + # --- #140 review: cleanup --with-manifest says what it takes besides the + # files, in the preview and in the run (TestCleanupNamesTheRecordBeforeItTakesIt, + # TestCleanupSaysWhyTheRecordStays, TestInstructionsAlreadyGoneHoldNothingBack), + # and the two guards CI found red on the branch. + ( + "the cleanup preview goes back to naming only the files the manifest lists", + "internal/cli/cleanup.go", + "record := previewRecord(cands, run)", + "record := previewRecord(cands, cleanupRun{})", + "TestCleanupNamesTheRecordBeforeItTakesIt", + ), + ( + "the run stops saying what it did with the manifest and the instructions", + "internal/cli/cleanup.go", + 'sayRecord(out, report.Record, "removed", "kept ")', + 'sayRecord(io.Discard, report.Record, "removed", "kept ")', + "TestCleanupNamesTheRecordBeforeItTakesIt", + ), + ( + "a file that will stay no longer holds the record back in the preview", + "internal/cli/cleanup.go", + "if !c.Removable(run.force) && c.Disposition != audit.Absent {", + "if !c.Removable(run.force) && c.Disposition == audit.Ready {", + "TestCleanupSaysWhyTheRecordStays", + ), + ( + "a record held back by a file left behind is reported as removed", + "internal/cli/cleanup.go", + 'Action: "kept", Reason: keptBecause(i, blocked)})', + 'Action: "removed", Reason: keptBecause(i, blocked)})', + "TestCleanupSaysWhyTheRecordStays", + ), + ( + "a record that would not go ends the run with no report", + "internal/cli/cleanup.go", + "return writeJSON(errOut, errOut, report, code)", + "return code", + "TestCleanupSaysWhyTheRecordStays", + ), + ( + "instructions already gone are promised for removal in the preview", + "internal/cli/cleanup.go", + "case i > 0 && isGone(p):", + "case i > 1 && isGone(p):", + "TestInstructionsAlreadyGoneHoldNothingBack", + ), + ( + "instructions already gone are reported as removed by the run", + "internal/cli/cleanup.go", + 'entries[i].Action, entries[i].Reason = "kept", "it is already gone"', + 'entries[i].Action, entries[i].Reason = "removed", ""', + "TestInstructionsAlreadyGoneHoldNothingBack", + ), + ( + "the purpose box takes the whole row of the manifest notes again", + "internal/gui/window/recipefolds.go", + "\t\tadd(recipe.KeyPurpose, text.FieldPurpose(), text.HintPurpose(),\n\t\t\tr.tips.Say(text.DetailPurpose()), parts.Text(n.purpose)),", + "\t\tparts.Wide(add(recipe.KeyPurpose, text.FieldPurpose(), text.HintPurpose(),\n\t\t\tr.tips.Say(text.DetailPurpose()), n.purpose)),", + "TestOnlyAPathTakesTheWholeRow", + ), + # --- O245: eject writes the file itself, and a UTF-16 recipe says where it + # came from (internal/guard/ejectfile_test.go). + ( + "eject -o puts the recipe on standard output as well as into the file", + "internal/cli/presetcmd.go", + "\tif to.set {\n\t\treturn writeEjected(to.name, expanded.Source, errOut)\n\t}", + "\tif to.set {\n\t\t_ = writeEjected(to.name, expanded.Source, errOut)\n\t}", + "TestEjectWritesTheFileByteForByteWhatItWouldPrint", + ), + ( + "eject -o takes a dash for the name of a file", + "internal/cli/presetcmd.go", + 'if to.set && (to.name == "" || to.name == "-") {', + 'if to.set && to.name == "" {', + "TestEjectRefusesAFileNameThatIsNotOne", + ), + ( + "a UTF-16 recipe gets the general refusal again", + "internal/recipe/recipe.go", + "if isUTF16(src) {", + "if isUTF16(src[:0]) {", + "TestARecipeSavedAsUTF16IsRefusedAndSaysWhere", + ), + ( + "only one byte order of UTF-16 is recognised", + "internal/recipe/canonical.go", + 'return strings.HasPrefix(s, "\\xff\\xfe") || strings.HasPrefix(s, "\\xfe\\xff")', + 'return strings.HasPrefix(s, "\\xff\\xfe")', + "TestARecipeSavedAsUTF16IsRefusedAndSaysWhere", + ), + ( + "the command line stops naming the way round a UTF-16 recipe", + "internal/cli/errors.go", + "if errors.As(err, &syntax) && syntax.UTF16 {", + "if errors.As(err, &syntax) && !syntax.UTF16 {", + "TestARecipeSavedAsUTF16IsRefusedAndSaysWhere", + ), + # --- #140 second review: cleanup takes only the instructions named after + # its manifest, and the window escapes the instructions it could not write. + ( + "cleanup takes instructions named after another manifest", + "internal/cli/cleanup.go", + "if name != manifest.InstructionsName(filepath.Base(path)) {", + "if name == manifest.InstructionsName(filepath.Base(path))+\"/\" {", + "TestCleanupLeavesInstructionsNotNamedAfterItsManifest", + ), + ( + "instructions cleanup left alone vanish from the run's report", + "internal/cli/cleanup.go", + "if run.notOurs != \"\" {\n\t\treport.Record = append(", + "if run.notOurs == \"/\" {\n\t\treport.Record = append(", + "TestCleanupLeavesInstructionsNotNamedAfterItsManifest", + ), + ( + "the preview stops naming instructions cleanup will leave alone", + "internal/cli/cleanup.go", + "if run.notOurs != \"\" {\n\t\tentries = append(", + "if run.notOurs == \"/\" {\n\t\tentries = append(", + "TestCleanupLeavesInstructionsNotNamedAfterItsManifest", + ), + ( + "the window names the instructions it could not write raw", + "internal/gui/window/run.go", + "text.InstructionsNotSaved(core.Shown(saved.Missed.Path), core.ShownText(saved.Missed.Err.Error()))", + "text.InstructionsNotSaved(saved.Missed.Path, core.ShownText(saved.Missed.Err.Error()))", + "TestTheWindowSaysInstructionsItCouldNotWriteWithTheEscape", + ), + ( + "the window says why the instructions were not written in the system's raw words", + "internal/gui/window/run.go", + "text.InstructionsNotSaved(core.Shown(saved.Missed.Path), core.ShownText(saved.Missed.Err.Error()))", + "text.InstructionsNotSaved(core.Shown(saved.Missed.Path), saved.Missed.Err.Error())", + "TestTheWindowSaysInstructionsItCouldNotWriteWithTheEscape", + ), + # --- the WinGet and Chocolatey packages and their renderer (2026-09-25) --- + ( + "the Chocolatey nuspec is written under the template's name instead of the package's", + ".github/scripts/build_packages.py", + ' rest = package.choco_id + ".nuspec"', + ' rest = "package.nuspec"', + "TestThePackagesRenderFromAReleasesChecksums", + ), + ( + "a WinGet template carries a version typed by hand", + "packaging/winget/version.yaml.in", + "PackageVersion: {{VERSION}}", + "PackageVersion: 0.4.0", + "TestNoPackageSourceCarriesAVersionNumber", + ), + ( + "WinGet reaches the window through a link that leaves its renderer behind", + "packaging/winget/installer.window.yaml.in", + "ArchiveBinariesDependOnPath: true", + "ArchiveBinariesDependOnPath: false", + "TestEveryWingetPackageKeepsItsProgramBesideItsFiles", + ), + ( + "the command line package stops offering the arm64 archive the release builds", + ".github/scripts/build_packages.py", + '"tfg_{version}_windows_{arch}.zip", ("amd64", "arm64"), "tfg"),', + '"tfg_{version}_windows_{arch}.zip", ("amd64",), "tfg"),', + "TestEachWingetPackageOffersTheArchitecturesTheReleaseBuilds", + ), + ( + "the release renames the window archive and the packages still download the old name", + ".github/workflows/release.yml", + 'base="tfg-gui_${version}_${label}_${arch}"', + 'base="tfg-window_${version}_${label}_${arch}"', + "TestThePackagesDownloadWhatTheReleaseBuilds", + ), + ( + "the window package installs whatever the address answers, unchecked", + "packaging/chocolatey/tools/chocolateyinstall.window.ps1.in", + " -Checksum64 '{{SHA256_AMD64}}' `\n", + "", + "TestTheChocolateyPackagesCheckWhatTheyDownload", + ), + ( + "the command line package checks its download with a weaker hash", + "packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in", + "-ChecksumType64 'sha256'", + "-ChecksumType64 'md5'", + "TestTheChocolateyPackagesCheckWhatTheyDownload", + ), + ( + "the Chocolatey icon follows a branch instead of the tag", + ".github/scripts/build_packages.py", + '"https://cdn.jsdelivr.net/gh/%s@%s/%s" % (repo, tag, ICON)', + '"https://cdn.jsdelivr.net/gh/%s@%s/%s" % (repo, "main", ICON)', + "TestTheChocolateyIconIsAPinnedCdnAddress", + ), + ( + "typing tfg-gui holds the terminal until the window closes", + "packaging/chocolatey/tools/chocolateyinstall.window.ps1.in", + 'New-Item -ItemType File -Path "$exe.gui" -Force | Out-Null', + "Write-Host ''", + "TestTheWindowPackageStartsWhereAPersonCanWrite", + ), + ( + "the Start menu shortcut starts the window in a folder the person cannot write to", + "packaging/chocolatey/tools/chocolateyinstall.window.ps1.in", + " -WorkingDirectory '%USERPROFILE%' `", + " -WorkingDirectory $programDir `", + "TestTheWindowPackageStartsWhereAPersonCanWrite", + ), + ( + "the uninstall removes a shortcut that belongs to somebody else", + "packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in", + "if ($target.StartsWith($toolsDir + '\\', [StringComparison]::OrdinalIgnoreCase)) {", + "if ($target -ne $null) {", + "TestTheWindowPackageStartsWhereAPersonCanWrite", + ), + ( + "the command line shim stops waiting, so a script reads no exit code", + "packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in", + "Write-Host 'Type tfg help to see the commands.'", + "New-Item -ItemType File -Path (Join-Path $toolsDir 'tfg/tfg.exe.gui') -Force | Out-Null", + "TestTheWindowPackageStartsWhereAPersonCanWrite", + ), + ( + "a package ends the program before an upgrade", + "packaging/chocolatey/tools/chocolateybeforemodify.ps1.in", + "if ($running.Count -gt 0) {", + "if ($running.Count -gt 0) {\n $running | Stop-Process -Force", + "TestNoPackageScriptEndsTheProgram", + ), + ( + "a package script picks up a character outside ASCII", + "packaging/chocolatey/tools/chocolateyinstall.window.ps1.in", + "Write-Host 'Start it from the Start menu, or type tfg-gui in a terminal.'", + "Write-Host 'Start it from the Start menu, or type tfg-gui in a terminal, café.'", + "TestEveryPackageScriptIsASCII", + ), + ( + "the description in both feeds picks up a semicolon", + ".github/scripts/build_packages.py", + '"anything that takes a file. Pick a format and a size and you get a file of exactly "', + '"anything that takes a file; pick a format and a size and you get a file of exactly "', + "TestThePackageTextFollowsThePunctuationRule", + ), + ( + "the packages name a licence the program is not under", + ".github/scripts/build_packages.py", + 'LICENCE = "GPL-3.0-only"', + 'LICENCE = "GPL-3.0-or-later"', + "TestThePackagesNameTheProductAndLicenceTheProgramDoes", + ), + ( + "a release candidate is refused without saying it is one", + ".github/scripts/build_packages.py", + '-.+", tag):', + '-never-matches", tag):', + "TestTheRendererRefusesInputsThatLookFineAndAreWrong", + ), + ( + "the renderer writes packages into the repository", + ".github/scripts/build_packages.py", + " if os.path.normcase(out) == root or os.path.normcase(out).startswith(root + os.sep):", + " if False:", + "TestTheRendererRefusesInputsThatLookFineAndAreWrong", + ), + ( + "a refusal leaves half a package beside the destination", + ".github/scripts/build_packages.py", + " if os.path.isdir(work):\n shutil.rmtree(work)", + " pass", + "TestTheRendererRefusesInputsThatLookFineAndAreWrong", + ), + ( + "the renderer writes into a directory that already holds something", + ".github/scripts/build_packages.py", + " if os.path.exists(out) and (not os.path.isdir(out) or os.listdir(out)):", + " if os.path.exists(out) and not os.path.isdir(out):", + "TestTheRendererRefusesInputsThatLookFineAndAreWrong", + ), + ( + "a missing archive crashes the renderer instead of being named", + ".github/scripts/build_packages.py", + ' refuse("%s has no line for %s.%s Download the checksum file of the release "', + ' print("%s has no line for %s.%s Download the checksum file of the release "', + "TestTheRendererRefusesInputsThatLookFineAndAreWrong", + ), + ( + "a checksum written in capitals reaches the Chocolatey package as it is", + ".github/scripts/build_packages.py", + " sums[found.group(2).strip()] = found.group(1).lower()", + " sums[found.group(2).strip()] = found.group(1)", + "TestEverySpellingOfAChecksumFileRendersTheSamePackages", + ), + ( + "a checksum file saved with a byte order mark is refused", + ".github/scripts/build_packages.py", + ' with open(path, encoding="utf-8-sig") as handle:', + ' with open(path, encoding="utf-8") as handle:', + "TestEverySpellingOfAChecksumFileRendersTheSamePackages", + ), + ( + "a quote in a value ends the PowerShell string early", + ".github/scripts/build_packages.py", + ' (".ps1", "\'", "a single quote ends the PowerShell string it sits in"),\n', + "", + "TestTheRendererRefusesAValueThatWouldBreakItsFile", + ), + ( + "a colon in a value turns the rest of a YAML line into a key", + ".github/scripts/build_packages.py", + ' (".yaml", ": ", "YAML reads the rest as a key"),\n', + "", + "TestTheRendererRefusesAValueThatWouldBreakItsFile", + ), + ( + "a value no template uses any more stays in the renderer", + ".github/scripts/build_packages.py", + " if known - used:", + " if False:", + "TestTheRendererRefusesAValueThatWouldBreakItsFile", + ), + ( + "the README counts two formats fewer than the program ships", + "README.md", + "You pick one of its 29 formats and the size you want,", + "You pick one of its 27 formats and the size you want,", + "TestTheReadmeCountsTheFormatsItShips", + ), + # --- the package renderer refuses instead of crashing (2026-09-25, review of #143) --- + ( + "a link walks the packages into the repository", + ".github/scripts/build_packages.py", + " out = os.path.realpath(out)", + " out = os.path.abspath(out)", + "TestTheRendererRefusesAFileItCannotReadAndAPathThatLeadsIntoTheTree", + ), + # --- the package renderer after the review of #145 (2026-09-28) --- + ( + "a checksum file that is not UTF-8 crashes the renderer", + ".github/scripts/build_packages.py", + " except UnicodeDecodeError:\n refuse(\"%s is not UTF-8 text, so it is not a release's checksum file. %s\" % (path, hint))\n", + " except KeyError:\n pass\n", + "TestTheRendererRefusesAFileItCannotReadAndAPathThatLeadsIntoTheTree", + ), + ( + "a missing checksum file crashes the renderer", + ".github/scripts/build_packages.py", + " except OSError as err:\n refuse(\"cannot read the checksum file %s: %s. %s\" % (path, err.strerror or err, hint))\n", + " except KeyError:\n pass\n", + "TestTheRendererRefusesAFileItCannotReadAndAPathThatLeadsIntoTheTree", + ), + ( + "a file far too big is read as if it were a checksum file", + ".github/scripts/build_packages.py", + " if len(data) > SUMS_LIMIT:", + " if False:", + "TestTheRendererRefusesAFileItCannotReadAndAPathThatLeadsIntoTheTree", + ), + ( + "a device is read as if it were a checksum file", + ".github/scripts/build_packages.py", + " if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):", + " if False:", + "TestTheRendererRefusesAFileItCannotReadAndAPathThatLeadsIntoTheTree", + ), + ( + "a destination under a file crashes the renderer", + ".github/scripts/build_packages.py", + " except OSError as err:\n remove_empty(made)\n", + " except KeyError:\n remove_empty(made)\n", + "TestTheRendererRefusesAFileItCannotReadAndAPathThatLeadsIntoTheTree", + ), + ( + "a failed run leaves behind the folders it made", + ".github/scripts/build_packages.py", + " if not finished:\n remove_empty(made)", + " pass", + "TestTheRendererLeavesNothingBehindWhenItFailsPartWay", + ), + ( + "a write that fails after the working folder exists crashes the renderer", + ".github/scripts/build_packages.py", + " except OSError as err:\n refuse(\"cannot write the packages to %s", + " except KeyError:\n refuse(\"cannot write the packages to %s", + "TestTheRendererLeavesNothingBehindWhenItFailsPartWay", + ), + ( + "a destination nobody can look into crashes the renderer", + ".github/scripts/build_packages.py", + " except OSError as err:\n refuse(\"cannot look inside --out", + " except KeyError:\n refuse(\"cannot look inside --out", + "TestTheRendererLeavesNothingBehindWhenItFailsPartWay", + ), + ( + "the window stops asking the rule with its own directory, so a double click in the program folder offers to write there", + "internal/gui/window/open.go", + "return OfferedDirectory(dir, programDirectory(), home)", + 'return OfferedDirectory(dir, "", home)', + "TestTheWindowStartedWhereItShouldNotWriteOffersTheHomeDirectory", + ), + ( + "the window checks a remembered folder without its own directory, so the old offer under the program folder comes back", + "internal/gui/window/open.go", + "LeftByTheOldOffer(last, programDirectory())", + 'LeftByTheOldOffer(last, "")', + "TestTheWindowDoesNotOfferTheFolderTheOldOfferLeftBehind", + ), + ( + "a remembered bare tfg-out is kept, so a window started from Finder offers /tfg-out again", + "internal/gui/window/offered.go", + "clean := filepath.Clean(dir)", + "clean := filepath.Clean(dir); if !filepath.IsAbs(clean) { return false }", + "TestAFolderTheOldOfferLeftBehindIsNotOfferedAgain", + ), + ( + "the installer installs for one account, so the command line is not on PATH for a build agent", + "packaging/msi/tfg-setup.wxs.in", + 'Scope="perMachine">', + 'Scope="perUser">', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "an upgrade removes the old version before the new files are in place", + "packaging/msi/tfg-setup.wxs.in", + 'Schedule="afterInstallExecute"', + 'Schedule="afterInstallInitialize"', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "a rebuild of the same version installs beside the first build", + "packaging/msi/tfg-setup.wxs.in", + 'AllowSameVersionUpgrades="yes"', + 'AllowSameVersionUpgrades="no"', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the Restart Manager stays on, so an upgrade while tfg runs fails and ends the run", + "packaging/msi/tfg-setup.wxs.in", + '', + '', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the PATH entry stays behind after an uninstall", + "packaging/msi/tfg-setup.wxs.in", + 'System="yes" Permanent="no" />', + 'System="yes" Permanent="yes" />', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the installer puts the folder on the installing account's PATH only", + "packaging/msi/tfg-setup.wxs.in", + 'Action="set" System="yes"', + 'Action="set" System="no"', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the shortcut starts the window in the Start menu folder instead of its own", + "packaging/msi/tfg-setup.wxs.in", + 'WorkingDirectory="INSTALLFOLDER"', + 'WorkingDirectory="ProgramMenuFolder"', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the shortcut starts the command line instead of the window", + "packaging/msi/tfg-setup.wxs.in", + 'Target="[INSTALLFOLDER]{{WINDOW_EXE}}"', + 'Target="[INSTALLFOLDER]tfg.exe"', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the installer brings in a WiX extension, whose code goes into the package", + "packaging/msi/tfg-setup.wxs.in", + '', + '', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the installer ends the running program before it upgrades", + "packaging/msi/tfg-setup.wxs.in", + '', + '', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the programs install into the 32-bit Program Files", + "packaging/msi/tfg-setup.wxs.in", + '', + '', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the installer gets a new UpgradeCode, so every machine keeps the old install beside the new", + ".github/scripts/build_msi.py", + 'UPGRADE_CODE = "7DB637B7-3BEB-4FBD-BE84-60822854AA2F"', + 'UPGRADE_CODE = "7DB637B7-3BEB-4FBD-BE84-60822854AA20"', + "TestTheInstallerIsTheShapeThatWasMeasured", + ), + ( + "the one sentence the installer shows a person breaks the punctuation rule", + "packaging/msi/tfg-setup.wxs.in", + "is already installed. Uninstall it first", + "is already installed; uninstall it first", + "TestTheInstallerTextFollowsThePunctuationRule", + ), + ( + "a release candidate gets an installer that takes the release's own version", + ".github/scripts/build_msi.py", + 'if "-" in tag:', + "if False:", + "TestTheInstallerIsBuiltForAReleaseOnly", + ), + ( + "a version the installer cannot hold is built anyway", + ".github/scripts/build_msi.py", + "if major > 255 or minor > 255 or patch > 65535:", + "if False:", + "TestTheInstallerIsBuiltForAReleaseOnly", + ), + ( + "two different copies of one document go into the installer, the second one silently dropped", + ".github/scripts/build_msi.py", + "if held.read() != archive.read(entry):", + "if False:", + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "a name inside an archive writes outside the folder it is unpacked into", + ".github/scripts/build_msi.py", + "if not safe_name(entry.filename):", + "if False:", + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "an installer is built without one of the two programs", + ".github/scripts/build_msi.py", + "if program.casefold() not in came_from:", + "if False:", + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "a run that stops leaves its unpacked archives behind", + ".github/scripts/build_msi.py", + "shutil.rmtree(staging, ignore_errors=True)", + "pass", + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "an installer already in the folder is written over", + ".github/scripts/build_msi.py", + "if os.path.exists(target):", + "if False:", + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "a missing archive ends in an error about something else", + ".github/scripts/build_msi.py", + "if not os.path.isfile(path):", + "if False:", + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "an archive that is not a zip ends in a traceback", + ".github/scripts/build_msi.py", + "except zipfile.BadZipFile as err:", + "except ZeroDivisionError as err:", + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "without WiX the script says so without the command that installs the pinned version", + ".github/scripts/build_msi.py", + '"version this project builds with:\\n" + install)', + '"version this project builds with.")', + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "a double quote in a value ends the attribute it lands in", + ".github/scripts/build_packages.py", + '(".wxs", \'"\', "a double quote ends the attribute it sits in"),', + "", + "TestTheRendererRefusesAValueThatWouldBreakItsFile", + ), + ( + "a WiX variable in a value is read by WiX", + ".github/scripts/build_packages.py", + '(".wxs", "$(", "WiX reads it as one of its own variables"),', + "", + "TestTheRendererRefusesAValueThatWouldBreakItsFile", + ), + ( + "markup in a value is read as markup by the installer source", + ".github/scripts/build_packages.py", + '(".wxs", "<", "the installer source reads it as markup"),', + "", + "TestTheRendererRefusesAValueThatWouldBreakItsFile", + ), + ( + "an ampersand in a value is read as markup by the installer source", + ".github/scripts/build_packages.py", + '(".wxs", "&", "the installer source reads it as markup"),', + "", + "TestTheRendererRefusesAValueThatWouldBreakItsFile", + ), + ( + "the installer's signature carries no timestamp and dies with the certificate", + ".github/scripts/sign_release.py", + '"/tr", TIMESTAMP_URL, "/td", "sha256", "/v", path]', + '"/td", "sha256", "/v", path]', + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "the installer's certificate is taken on trust instead of read back out of the file", + ".github/scripts/sign_release.py", + "signer = certificate_of(path)", + "signer = pin", + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "an installer signed by another certificate goes on the draft", + ".github/scripts/sign_release.py", + "if signer != pin:", + "if False:", + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "nothing asks whether the installer can be built before the card signs the programs", + ".github/scripts/sign_release.py", + " check_installer(args.tag, tree)", + " pass", + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "a release's draft counts as complete without its installer", + ".github/scripts/sign_release.py", + "if not is_candidate(tag) and len(installers) != 1:", + "if False:", + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "a candidate's draft carrying an installer counts as complete", + ".github/scripts/sign_release.py", + "if is_candidate(tag) and installers:", + "if False:", + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "the installer is built by the checkout's build_msi.py instead of the tag's", + ".github/scripts/sign_release.py", + 'script = os.path.join(tree, ".github", "scripts", "build_msi.py")', + 'script = os.path.join(".github", "scripts", "build_msi.py")', + "TestTheInstallerIsBuiltFromTheTaggedTreeNotTheCheckout", + ), + ( + "the exported tree is only part of what was tagged", + ".github/scripts/sign_release.py", + 'archive = subprocess.run(["git", "archive", "--format=tar", tag], capture_output=True)', + 'archive = subprocess.run(["git", "archive", "--format=tar", tag, "--", "internal"], capture_output=True)', + "TestTheInstallerIsBuiltFromTheTaggedTreeNotTheCheckout", + ), + ( + "a tag missing from the clone ends in git's error instead of the command that fetches it", + ".github/scripts/sign_release.py", + "if found.returncode != 0:", + "if False:", + "TestTheInstallerIsBuiltFromTheTaggedTreeNotTheCheckout", + ), + ( + "the signing script takes only a tag with -rc for a candidate, so a -beta gets an installer", + ".github/scripts/sign_release.py", + 'return "-" in tag', + 'return "-rc" in tag', + "TestEveryPlaceAsksTheSameQuestionOfACandidate", + ), + ( + "the installer job installs another WiX than the one build_msi.py builds with", + ".github/workflows/ci.yml", + "dotnet tool install --global wix --version 5.0.2", + "dotnet tool install --global wix --version 5.0.1", + "TestTheInstallerIsInstalledOnARunner", + ), + ( + "the installer job no longer asks tfg through PATH", + ".github/workflows/ci.yml", + "$said = ((& cmd.exe /c 'tfg version' 2>&1) | Out-String).Trim()", + "$said = $env:VERSION", + "TestTheInstallerIsInstalledOnARunner", + ), + ( + "the installer job no longer uninstalls, so what an uninstall leaves is asked of nothing", + ".github/workflows/ci.yml", + "$code = Msi '/x' $second 'uninstall.log'", + "$code = 0", + "TestTheInstallerIsInstalledOnARunner", + ), + ( + "the installer job runs on Linux, where there is nothing to install", + ".github/workflows/ci.yml", + " runs-on: windows-latest\n timeout-minutes: 20\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n with:\n persist-credentials: false\n\n - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0\n with:\n python-version: \"3.14\"\n\n - name: build the installer twice", + " runs-on: ubuntu-latest\n timeout-minutes: 20\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n with:\n persist-credentials: false\n\n - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0\n with:\n python-version: \"3.14\"\n\n - name: build the installer twice", + "TestTheInstallerIsInstalledOnARunner", + ), + ( + "the release check asks for an installer under another name than the one the build gives it", + ".github/workflows/verify-release.yml", + 'test -f "tfg-setup_${TAG#v}_windows_amd64.msi"', + 'test -f "tfg_setup_${TAG#v}_windows_amd64.msi"', + "TestTheReleaseCheckAsksForTheInstaller", + ), + ( + "the build names the installer differently from what the release check asks for", + ".github/scripts/build_msi.py", + 'NAME = "tfg-setup_{version}_windows_amd64.msi"', + 'NAME = "tfg-installer_{version}_windows_amd64.msi"', + "TestTheReleaseCheckAsksForTheInstaller", + ), + ( + "the release check accepts an installer whose signature carries no timestamp", + ".github/workflows/verify-release.yml", + "if (-not $sig.TimeStamperCertificate) { throw \"$($msi.Name): the signature carries no timestamp\" }", + "", + "TestTheReleaseCheckAsksForTheInstaller", + ), + ( + "the release check accepts an installer signed by another certificate", + ".github/workflows/verify-release.yml", + "if ($actual -ne $pinned) { throw \"$($msi.Name) was signed by a DIFFERENT certificate: $actual\" }", + "", + "TestTheReleaseCheckAsksForTheInstaller", + ), + ( + "the release check takes only a tag with -rc for a candidate, so a -beta page passes with an installer", + ".github/workflows/verify-release.yml", + '*-*) test "${#installers[@]}" = "0"', + '*-rc*) test "${#installers[@]}" = "0"', + "TestEveryPlaceAsksTheSameQuestionOfACandidate", + ), + ( + "the installer sorts below the command line's archives on the release page", + ".github/scripts/build_msi.py", + 'NAME = "tfg-setup_{version}_windows_amd64.msi"', + 'NAME = "tfg_setup_{version}_windows_amd64.msi"', + "TestTheInstallerSortsAmongThePrograms", + ), + ( + "the installer sorts above the window's archives on the release page", + ".github/scripts/build_msi.py", + 'NAME = "tfg-setup_{version}_windows_amd64.msi"', + 'NAME = "tfg-aaa-setup_{version}_windows_amd64.msi"', + "TestTheInstallerSortsAmongThePrograms", + ), + ( + "two archives holding LICENSE and License with different bytes put one over the other on Windows without a word", + ".github/scripts/build_msi.py", + "key = entry.filename.casefold()", + "key = entry.filename", + "TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll", + ), + ( + "a refusal while the tagged tree is out leaves it beside the release's files", + ".github/scripts/sign_release.py", + "shutil.rmtree(into, ignore_errors=True)", + "pass", + "TestTheInstallerIsBuiltFromTheTaggedTreeNotTheCheckout", + ), + ( + "the installer is signed with a name other than the one it carries", + ".github/scripts/build_msi.py", + 'print(values(parse_version(args.tag), args.tag)["APP_NAME"])', + 'print(values(parse_version(args.tag), args.tag)["PUBLISHER"])', + "TestTheInstallerIsSignedWithTheNameItCarries", + ), + ( + "the installer is signed with no name, so the elevation prompt shows digits", + ".github/scripts/sign_release.py", + 'command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256", "/d", name,', + 'command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256",', + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "the product name keeps the line end the tag's script printed after it", + ".github/scripts/sign_release.py", + "name = said.stdout.strip()", + "name = said.stdout", + "TestTheInstallerIsBuiltFromTheTaggedTreeNotTheCheckout", + ), + ( + "a tag whose script names no product signs the installer with an empty name", + ".github/scripts/sign_release.py", + "if said.returncode != 0 or not name:", + "if False:", + "TestTheInstallerIsBuiltFromTheTaggedTreeNotTheCheckout", + ), + ( + "the name is first asked for after the card has signed the programs", + ".github/scripts/sign_release.py", + 'print(" its signature will name it %r" % product_name(tag, tree))', + 'print(" its signature will name it")', + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "the installer is signed with a name typed into the signing script", + ".github/scripts/sign_release.py", + "name = product_name(tag, tree)", + 'name = "Testing Files Generator"', + "TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft", + ), + ( + "the README's table of presets leaves out a preset the binary ships", + "README.md", + "| `upload-validation` | Does my upload form take what it should and turn the rest away? |", + "", + "TestTheReadmeListsEveryPresetItShips", + ), + ( + "the README's table of presets words a question differently from the preset", + "README.md", + "Is a size limit enforced exactly where it is declared?", + "Is a size limit enforced?", + "TestTheReadmeListsEveryPresetItShips", + ), + ( + "the README's table of presets names a preset nothing registers", + "README.md", + "| `text-encoding` |", + "| `text-encodings` |", + "TestTheReadmeListsEveryPresetItShips", + ), + ( + "the social card shows a file over the limit as one the system should take", + "web/templates/social.html", + '1mb_over_1b.pdf1 048 577 B

reject', + '1mb_over_1b.pdf1 048 577 Baccept', + "TestTheLimitExampleIsWhatThePresetWrites", + ), + ( + "the README shows the file at the limit a byte short of what the preset writes", + "README.md", + "| `1mb_at_limit.pdf` | 1048576 |", + "| `1mb_at_limit.pdf` | 1048575 |", + "TestTheLimitExampleIsWhatThePresetWrites", + ), + ( + "the Polish first page tells the reader to take the file over the limit", + "web/content/pl/index.html", + '1mb_over_1b.pdf1048577odrzucić', + '1mb_over_1b.pdf1048577przyjąć', + "TestTheLimitExampleIsWhatThePresetWrites", + ), + ( + "a wrong row of the README's presets table hides behind a right one below it", + "README.md", + "| `size-boundaries` | Is a size limit enforced exactly where it is declared? |", + "| `size-boundaries` | Is a size limit enforced? |\n| `size-boundaries` | Is a size limit enforced exactly where it is declared? |", + "TestTheReadmeListsEveryPresetItShips", + ), + ( + # Pages per preset, 2026-09-29 (docs/PRESET-PAGES-2026-09-29.md). The + # English words of a preset are copies of the registry, and these + # four entries break one copy each. + "the English page says a preset catches something the registry does not", + "web/content/en/site.json", + '"off by one errors at the limit",', + '"off by one errors near the limit",', + "TestEveryPresetIsDescribedInEveryLanguage", + ), + ( + "the English page asks a preset question the registry no longer asks", + "web/content/en/site.json", + '"question": "Is a size limit enforced exactly where it is declared?",', + '"question": "Is a size limit enforced where it is declared?",', + "TestEveryPresetIsDescribedInEveryLanguage", + ), + ( + "the English page describes a preset setting differently from the registry", + "web/content/en/site.json", + '"spread": "How far either side of the limit to reach, as a list of sizes."', + '"spread": "How far either side of the limit to go, as a list of sizes."', + "TestEveryPresetIsDescribedInEveryLanguage", + ), + ( + "the English page words what a preset setting takes differently from the registry", + "web/content/en/site.json", + '"sizes separated by commas": "sizes separated by commas",', + '"sizes separated by commas": "a list of sizes",', + "TestEveryPresetIsDescribedInEveryLanguage", + ), + ( + # A Polish page one sentence short renders without a word missing, + # which is why the count is asked rather than left to the render. + "the Polish page of a preset loses one thing it catches and still renders", + "web/content/pl/site.json", + '"błędy o jeden na samym limicie",', + '', + "TestEveryPresetIsDescribedInEveryLanguage", + ), + ( + "the English formats page words what a format setting takes differently from the registry", + "web/content/en/site.json", + '"the password, in plain text": "the password, in plain text"', + '"the password, in plain text": "the password"', + "TestEveryLanguageDescribesEverythingTheProgramCanProduce", + ), + ( + "a reaction a manifest can declare loses its Polish meaning", + "web/content/pl/site.json", + '"sanitize": "System powinien', + '"sanitized": "System powinien', + "TestEveryReactionHasItsMeaningInEveryLanguage", + ), + ( + "the sitemap leaves out the pages made from the registry", + "internal/site/render.go", + '\t\t\tb.WriteString(" \\n")', + '\t\t\tif page.Parent != "" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tb.WriteString(" \\n")', + "TestTheSitemapNeedsNoSchemaButItsOwn", + ), + ( + "the header lists the page of every preset as well as the list of them", + "internal/site/render.go", + '\t\tif item.Parent != "" {\n\t\t\tcontinue\n\t\t}', + '\t\tif false && item.Parent != "" {\n\t\t\tcontinue\n\t\t}', + "TestTheSiteSaysWhatTheToolSays", + ), + ( + "the page of a preset leads back to the home page instead of the list above it", + "internal/site/render.go", + '\t\tv.Up = &NavItem{Label: up.Nav, URL: pageURL(lang, up)}', + '\t\tv.Up = &NavItem{Label: up.Nav, URL: pageURL(lang, lang.Pages[0])}', + "TestTheSiteSaysWhatTheToolSays", + ), + ( + "a text setting is described as text again rather than by what it takes", + "internal/site/view.go", + '\t\tif p.Shape != "" {\n\t\t\treturn v.Term(p.Shape)', + '\t\tif false && p.Shape != "" {\n\t\t\treturn v.Term(p.Shape)', + "TestTheSiteSaysWhatTheToolSays", + ), + ( + "the page of a preset stops marking a default as our placeholder", + "internal/site/presets.go", + "Detail: detail, Placeholder: s.Placeholder})", + "Detail: detail, Placeholder: false})", + "TestTheSiteSaysWhatTheToolSays", + ), + ( + # Every id today passes, so this is aimed at whether the check runs on + # every id rather than at a bad one - there is none to feed it. + "the check that a preset id can be an address stops passing ids with a dash", + "internal/site/presets.go", + "regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)", + "regexp.MustCompile(`^[a-z0-9]+$`)", + "TestTheSiteSaysWhatTheToolSays", + ), + ( + "the page of a preset counts its targets as its files", + "internal/guard/sitepresets_test.go", + "Files: b.Files,", + "Files: b.Targets,", + "TestTheSiteSaysWhatTheToolSays", + ), + ( + "the reactions on the page of a preset are counted twice over", + "internal/guard/sitepresets_test.go", + "\t\tcounts[f.Expected.Outcome]++", + "\t\tcounts[f.Expected.Outcome] += 2", + "TestTheSiteSaysWhatTheToolSays", + ), + ( + # O252, 2026-09-29 (docs/O252-ANALIZA-2026-09-29.md). The primitive + # going back to what it replaced. + "the publish goes back to a rename, which replaces a name somebody holds", + "internal/core/publish.go", + "\treturn PublishThrough(tmp, final, renameNoReplace, os.Link)", + "\treturn os.Rename(tmp, final)", + "TestPublishNeverReplacesANameSomebodyHolds", + ), + ( + "a generated file is given its name by a rename again, over a file put there during the run", + "internal/engine/parallel.go", + "\tif err := core.Publish(tmp, final); err != nil {", + "\tif err := os.Rename(tmp, final); err != nil {", + "TestAFileTakenDuringTheRunIsNotWrittenOver", + ), + ( + "a failure that is not unsupported walks on to the next way of naming the file", + "internal/core/publish.go", + "\tif !errors.Is(err, errors.ErrUnsupported) {\n\t\treturn &os.LinkError{Op: \"publish\", Old: tmp, New: final, Err: err}", + "\tif false && !errors.Is(err, errors.ErrUnsupported) {\n\t\treturn &os.LinkError{Op: \"publish\", Old: tmp, New: final, Err: err}", + "TestPublishFallbacksRefuseATakenNameToo", + ), + ( + "a clean-up removes by name again, whatever holds the name by then", + "internal/core/writenew.go", + "\tif !sameWrite(own, now) {\n\t\treturn &NotOursError{Path: path}", + "\tif false && !sameWrite(own, now) {\n\t\treturn &NotOursError{Path: path}", + "TestGivingAClaimedNameBackSparesWhatTookItsName", + ), + ( + # A real run meets a left reservation twice, a dry run once - so this is + # aimed at the one road where the preflight is the whole answer. + "a preview stops asking whether a run holds the manifest's name", + "internal/engine/preflight.go", + "\tif path := manifest.ReservationPath(ManifestPath(opt)); exists(path) {", + "\tif path := manifest.ReservationPath(ManifestPath(opt)); exists(path) && false {", + "TestALeftReservationStopsEvenADryRun", + ), + ( + "the instructions and an ejected recipe are named before their bytes reach the disk", + "internal/core/writenew.go", + "\tif err := f.Sync(); err != nil {\n\t\treturn Finish(f, err)", + "\tif err := error(nil); err != nil {\n\t\treturn Finish(f, err)", + "TestWhatIsRenamedIntoPlaceIsOnTheDiskFirst", + ), + ( + # NOT CAUGHT on 2026-09-29, and the reason is the machine, not the + # guard: the guard's path to MoveFile is over 260 characters, but the + # Go runtime marks every process long path aware on Windows 10 1703 and + # later, and LongPathsEnabled is 1 here, so MoveFile takes the path + # without the prefix. Only a machine with LongPathsEnabled 0 can catch + # it (docs/O252-ANALIZA-2026-09-29.md, O256). + "a long path reaches MoveFile without the prefix that lets it past 260 characters", + "internal/core/publish_windows.go", + "\tif len(path) < 248 ||", + "\tif true ||", + "TestANameAsLongAsEverySystemStoresIsWritten", + ), + ( + # Repointed on 2026-09-29, when O252 moved the write path (the lock now + # keeps what it is, the reservation is the temporary name, eject goes + # through core.WriteNew). Same labels, same guards, new code. + "the run stops taking the directory, so nothing holds it while the files are written", + "internal/engine/engine.go", + "\tlock, err := claimRunLock(lockPath)", + "\tlock, err := os.FileInfo(nil), error(nil)", + "TestTheDirectoryIsHeldWhileTheRunWritesAndGivenBackWhenItEnds", + ), + ( + "the run keeps the directory after it ends, so nothing can generate there again", + "internal/engine/engine.go", + "defer releaseRunLock(lockPath, lock)", + "defer func() { _, _ = lockPath, lock }()", + "TestAStoppedRunGivesTheDirectoryBack", + ), + ( + "eject -o writes over a file that is already there", + "internal/cli/presetcmd.go", + "\tif _, err := core.WriteNew(path, source, 0o644); err != nil {", + "\tif err := core.ReplaceFile(path, source); err != nil {", + "TestEjectLeavesAFileAlreadyThereAsItIs", + ), + ( + "eject -o writes something other than what eject prints", + "internal/cli/presetcmd.go", + "\tif _, err := core.WriteNew(path, source, 0o644); err != nil {", + "\tif _, err := core.WriteNew(path, source[:len(source)/2], 0o644); err != nil {", + "TestEjectWritesTheFileByteForByteWhatItWouldPrint", + ), + ( + 'the manifest goes back to being renamed into place without reaching the disk', + 'internal/manifest/reservation.go', + 'if err := f.Sync(); err != nil {', + 'if err := error(nil); err != nil {', + 'TestWhatIsRenamedIntoPlaceIsOnTheDiskFirst', + ), + ( + 'the manifest is written through its reservation without asking whether it is still ours', + 'internal/manifest/reservation.go', + '\tf, err := core.OpenOwn(r.tmp, r.own)', + '\tf, err := os.OpenFile(r.tmp, os.O_WRONLY, 0)', + 'TestAReservationSwappedForALinkIsNotWrittenThrough', + ), + ( + 'the manifest is named before its bytes reach the disk', + 'internal/manifest/reservation.go', + '\tif err := f.Sync(); err != nil {\n\t\treturn core.Finish(f, err)', + '\tif err := error(nil); err != nil {\n\t\treturn core.Finish(f, err)', + 'TestWhatIsRenamedIntoPlaceIsOnTheDiskFirst', + ), + ( + 'the marker gets a second spelling again, the way it had one until 2026-09-06', + 'internal/manifest/reservation.go', + '\treturn core.SiblingPath(path, core.WritingMarker)', + '\treturn core.SiblingPath(path, ".tfg-writing")', + 'TestEachLeftoverMarkerIsSpelledInOnePlace', + ), + ( + 'the manifest writes its temporary file with a plain create again', + 'internal/manifest/reservation.go', + '\tf, err := core.CreateNew(tmp, 0o666)', + '\tf, err := os.Create(tmp)', + 'TestAHeldTemporaryNameStopsTheWriteRatherThanGoingThroughIt', + ), + ( + 'the manifest name stops being exclusive, so two runs both take it', + 'internal/manifest/reservation.go', + '\tf, err := core.CreateNew(tmp, 0o666)', + '\tf, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE, 0o666)', + 'TestAClaimedNameCannotBeClaimedTwice', + ), + ( + 'the claimed name is never filled in with the manifest', + 'internal/manifest/reservation.go', + '\treturn own, core.Publish(tmp, final)', + '\treturn own, nil', + 'TestAManifestIsStillWrittenWhenNothingIsThere', + ), + ( + 'saving a manifest goes back to reading every look failure as an empty slot', + 'internal/manifest/reservation.go', + '\tcase !errors.Is(err, fs.ErrNotExist):', + '\tcase err != nil && !errors.Is(err, err):', + 'TestSavingAManifestTellsAnEmptySlotFromAnUnreadableOne', + ), + ( + 'every failure to take the manifest name is called a collision', + 'internal/engine/engine.go', + 'if errors.Is(err, fs.ErrExist) {\n\t\treturn nil, &CollisionError{Path: manifestPath, Manifest: true}', + 'if !errors.Is(err, fs.ErrExist) {\n\t\treturn nil, &CollisionError{Path: manifestPath, Manifest: true}', + 'TestADirectoryWeCannotWriteInIsAPermissionProblem', + ), + ( + 'a long manifest name is written under the plain join again', + 'internal/manifest/reservation.go', + '\treturn core.SiblingPath(path, core.WritingMarker)', + '\treturn path + core.WritingMarker', + 'TestAManifestNamedAsLongAsEverySystemStoresIsSaved', + ), + ( + 'the manifest stops going through the function that flushes before the rename', + 'internal/manifest/reservation.go', + '\town, err := saveReserved(f, m, r.tmp, r.final)', + '\town, err := core.Finish(f, m.Encode(f))', + 'TestWhatIsRenamedIntoPlaceIsOnTheDiskFirst', + ), + ( + # Review of #150, 2026-09-29: a leftover temporary name of eject -o + # was reported as the recipe itself being there. + "eject -o calls its own leftover temporary name the recipe", + "internal/cli/presetcmd.go", + "\t\tif errors.As(err, &held) {\n\t\t\tfmt.Fprintf(errOut, \"tfg: %s is already there. It is the temporary name", + "\t\tif false && errors.As(err, &held) {\n\t\t\tfmt.Fprintf(errOut, \"tfg: %s is already there. It is the temporary name", + "TestEjectNamesItsOwnLeftoverRatherThanTheRecipe", + ), + ( + 'the file ceiling is left one line above the biggest file, which grants headroom nobody decided to grant and lets the next arrival in unnoticed', + 'internal/guard/codeshape_test.go', + '\tlongestFile = 399', + '\tlongestFile = 400', + 'TestTheCeilingsAreTodaysMeasurementAndNotALooserNumber', + ), + ( + 'the last resort renames over a name that is taken', + 'internal/core/publish.go', + '\tcase lookErr == nil:\n\t\treturn &os.LinkError{Op: "publish", Old: tmp, New: final, Err: fs.ErrExist}', + '\tcase false:\n\t\treturn &os.LinkError{Op: "publish", Old: tmp, New: final, Err: fs.ErrExist}', + 'TestPublishFallbacksRefuseATakenNameToo', + ), + ( + 'the lock stops being empty, so a reader could find something half written in it', + 'internal/engine/engine.go', + '\town, err := core.Finish(fh, nil)', + '\t_, _ = fh.WriteString("x")\n\town, err := core.Finish(fh, nil)', + 'TestTheRunLockCarriesNothing', + ), + ( + 'a manifest that could not be written leaves its empty claim behind, so the files it should have named cannot be cleaned up', + 'internal/manifest/reservation.go', + '\t\t_ = core.RemoveOwn(r.tmp, r.own)\n\t\treturn err\n\t}\n\treturn nil', + '\t\treturn err\n\t}\n\treturn nil', + 'TestAManifestThatCouldNotBeWrittenLeavesNoManifest', + ), + ( + # The only half of plantLink a machine off CI can prove. Whether CI + # really fails rather than skips is answered by the Windows job itself. + "a case built on a symbolic link skips on CI too, where no one sees it did not run", + "internal/guard/symlinkescape_test.go", + "\treturn ci == \"\"", + "\treturn true", + "TestACaseBuiltOnALinkSkipsOnlyOffCI", + ), + ( + # Outside review of #151: a guard on the predicate alone stayed green + # when plantLink stopped asking it. + "a refused link skips on CI too, because plantLink stops asking whether it may", + "internal/guard/symlinkescape_test.go", + "\tif !linkCasesMaySkip(ci) {", + "\tif false {", + "TestACaseBuiltOnALinkSkipsOnlyOffCI", + ), + ( + "every refused link counts as a missing privilege, so a real refusal skips off CI", + "internal/guard/symlinkescape_test.go", + "\treturn errors.Is(err, errPrivilegeNotHeld) || errors.Is(err, fs.ErrPermission)", + "\treturn true", + "TestACaseBuiltOnALinkSkipsOnlyOffCI", + ), + ( + "a PDF drops the author it was asked for", + "internal/format/pdf/info.go", + "\tentry(&b, \"Author\", i.author)\n", + "\tentry(&b, \"Author\", \"\")\n", + "TestEveryPdfSettingSurvivesItsReferenceTool", + ), + ( + "a PDF writes text outside ASCII between brackets, where a reader takes it for PDFDocEncoding", + "internal/format/pdf/info.go", + "\t\tif s[i] < 0x20 || s[i] > 0x7e {\n", + "\t\tif false && (s[i] < 0x20 || s[i] > 0x7e) {\n", + "TestEveryPdfSettingSurvivesItsReferenceTool", + ), + ( + "a PDF date loses the zone it was asked in", + "internal/format/pdf/info.go", + "\t\tpdf += zone[:3] + \"'\" + zone[4:] + \"'\"\n", + "\t\tpdf += \"\"\n", + "TestEveryPdfSettingSurvivesItsReferenceTool", + ), + ( + "a PDF date that no calendar has is written instead of refused", + "internal/format/pdf/info.go", + "\tif t.Year() != y || int(t.Month()) != mo || t.Day() != d {\n", + "\tif false && (t.Year() != y || int(t.Month()) != mo || t.Day() != d) {\n", + "TestABadPropertyValueIsTheCallersMistakeNotOurs", + ), + ( + "a landscape PDF page keeps standing upright", + "internal/format/pdf/settings.go", + "\tif o.orientation == orientLandscape || (o.orientation == mixed && i%2 == 1) {\n", + "\tif false && (o.orientation == orientLandscape || (o.orientation == mixed && i%2 == 1)) {\n", + "TestEveryPdfSettingSurvivesItsReferenceTool", + ), + ( + "a mixed PDF gives every page the first size of the cycle", + "internal/format/pdf/settings.go", + "\ts := o.sizes[i%len(o.sizes)]\n", + "\ts := o.sizes[0]\n", + "TestEveryPdfSettingSurvivesItsReferenceTool", + ), + ( + "a mixed PDF of one page is written as a uniform page instead of refused", + "internal/format/pdf/settings.go", + "\t\tif raw == mixed && pages < 2 {\n", + "\t\tif false && raw == mixed && pages < 2 {\n", + "TestABadPropertyValueIsTheCallersMistakeNotOurs", + ), + ( + "a PDF page that asks to be turned never says so", + "internal/format/pdf/document.go", + "\tif m.opts.rotate != 0 {\n", + "\tif false && m.opts.rotate != 0 {\n", + "TestEveryPdfSettingSurvivesItsReferenceTool", + ), + ( + "a PDF page that nobody turned says /Rotate 0, bytes the default file never had", + "internal/format/pdf/document.go", + "\tif m.opts.rotate != 0 {\n", + "\tif true || m.opts.rotate != 0 {\n", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "a landscape PDF grows by a byte or two depending on the seed", + "internal/format/pdf/document.go", + "\tbody.Write([]byte{'%', 0xe2, 0xe3, 0xcf, 0xd3, '\\n'})\n", + "\tbody.Write([]byte{'%', 0xe2, 0xe3, 0xcf, 0xd3, '\\n'})\n\tif m.opts.orientation == orientLandscape {\n\t\tbody.WriteString(\"%\" + strings.Repeat(\"x\", int(m.seed%3)) + \"\\n\")\n\t}\n", + "TestTheMinimumStandsWhateverTheSeedIsUnderEveryDeclaredChoice", + ), + ( + "a block of settings declared in two places registers", + "internal/format/registry.go", + "\tif g, split := splitGroup(d.Properties); split {\n", + "\tif g, split := splitGroup(d.Properties); split && false {\n", + "TestABlockOfSettingsDeclaredInTwoPlacesIsRefused", + ), + ( + "tfg formats stops naming a block of settings", + "internal/cli/formats.go", + "\t\tif p.Group != \"\" && (i == 0 || d.Properties[i-1].Group != p.Group) {\n", + "\t\tif false && p.Group != \"\" && (i == 0 || d.Properties[i-1].Group != p.Group) {\n", + "TestASettingsBlockIsNamedWhereItStartsOnBothOutputs", + ), + ( + "the machine readable list stops saying which block a setting is in", + "internal/cli/formats.go", + "\t\t\tGroup: p.Group,\n", + "\t\t\tGroup: \"\",\n", + "TestASettingsBlockIsNamedWhereItStartsOnBothOutputs", + ), + ( + "the window stops drawing the name of a block of settings", + "internal/gui/parts/property.go", + "\t\t\tobjects = append(objects, Subheading(text.SettingGroup(owner, p.Group)))\n", + "\t\t\t_ = Subheading\n", + "TestEveryScreenStillDrawsItsStoredPicture", + ), + ( + "an empty text box says its value is worked out from the size again", + "internal/gui/parts/property.go", + "\tcase p.Kind == format.PropertyText:\n\t\treturn text.PlaceholderNotSet()\n", + "\tcase false && p.Kind == format.PropertyText:\n\t\treturn text.PlaceholderNotSet()\n", + "TestEveryScreenStillDrawsItsStoredPicture", + ), + ( + "the packages give a copyright the program does not print", + ".github/scripts/build_packages.py", + 'COPYRIGHT = "Copyright (C) 2026 DonislawDev"', + 'COPYRIGHT = "Copyright (C) 2025 DonislawDev"', + "TestEveryPackageCarriesTheCopyrightTheProgramPrints", + ), + ( + "the Chocolatey package loses its copyright element", + "packaging/chocolatey/package.nuspec.in", + "{{COPYRIGHT}}", + "", + "TestEveryPackageCarriesTheCopyrightTheProgramPrints", + ), + ( + "the WinGet package loses its copyright field", + "packaging/winget/locale.en-US.yaml.in", + "Copyright: {{COPYRIGHT}}", + "# no copyright", + "TestEveryPackageCarriesTheCopyrightTheProgramPrints", + ), + ( + "the window knows its screens by their translated word again", + "internal/gui/window/open.go", + " showing = item.ID", + " showing = item.Text", + "TestTwoTabsCalledTheSameAreStillTwoScreens", + ), + ( + "opening Preferences saves what its list only shows", + "internal/gui/window/preferences.go", + "p.pick.OnChanged = nil", + "p.pick.OnChanged = p.chose", + "TestOpeningPreferencesChangesNothingSaved", + ), + ( + "choosing a language does not save it", + "internal/gui/window/preferences.go", + "p.host.Remembered().RememberLanguage(p.choices.tagOf[option])", + "_ = p.choices.tagOf[option]", + "TestChoosingALanguageSavesItAndOffersARestart", + ), + ( + "Restart now stays on while the screen is busy", + "internal/gui/window/preferences.go", + "if p.pending() && !p.busy() {", + "if p.pending() {", + "TestRestartWaitsForTheRunAndClosesTheWayAPersonDoes", + ), + ( + "Restart now asks for no new window", + "internal/gui/window/preferences.go", + "p.host.RestartWhenClosed()", + "_ = p.host", + "TestRestartWaitsForTheRunAndClosesTheWayAPersonDoes", + ), + ( + "the busy state tells nobody that it changed", + "internal/gui/window/runbusy.go", + "if was != occupied && b.changed != nil {", + "if false && b.changed != nil {", + "TestRestartWaitsForTheRunAndClosesTheWayAPersonDoes", + ), + ( + "Restart now closes the window without the close button's way", + "internal/gui/window/open.go", + "prefs := NewPreferences(h, func() { leave() },", + "prefs := NewPreferences(h, func() { h.Close() },", + "TestRestartWaitsForTheRunAndClosesTheWayAPersonDoes", + ), + ( + "Forget stops forgetting for the rest of the run", + "internal/gui/window/remembered.go", + "f.forgot = true", + "f.forgot = false", + "TestForgetClearsWhatTheWindowKeepsAndTheCloseWritesNothingBack", + ), + ( + "a language is matched loosely enough to hand one script for another", + "internal/gui/text/languages.go", + "if confidence < language.High {", + "if confidence < language.Low {", + "TestTheWindowSpeaksTheLanguageItWasToldTo", + ), + ( + "a language somebody chose loses to the system", + "internal/gui/text/languages.go", + "if tag, ok := closest(saved, carried); ok {", + "if tag, ok := closest(system, carried); ok {", + "TestTheWindowSpeaksTheLanguageItWasToldTo", + ), + ( + "the pseudo language disguises the values it is handed", + "internal/gui/text/languages.go", + 'if !strings.HasPrefix(s, "{{") {', + "if true {", + "TestThePseudoLanguageDisguisesTheWordsAndKeepsTheValues", + ), + ( + "asking for the pseudo language gives English", + "internal/gui/text/catalogue.go", + "if lang == Pseudo {", + 'if lang == "never" {', + "TestThePseudoLanguageDisguisesTheWordsAndKeepsTheValues", + ), + ( + "the Polish catalogue loses an entry", + "internal/gui/text/locale/pl.json", + ' "ButtonForget": { "other": "Zapomnij" },', + "", + "TestEveryLanguageSaysEverythingTheWindowSays", + ), + ( + "the Polish catalogue says a letter the typeface lacks", + "internal/gui/text/locale/pl.json", + '"LanguageName": { "other": "Polski" }', + '"LanguageName": { "other": "Polski 語" }', + "TestEveryLetterEveryLanguageSaysIsInTheTypeface", + ), + ( + "a Donate button stands on Preferences while the tab guard still names it as the screen without one", + "internal/gui/window/preferences.go", + "\t\t\tp.forget)),", + "\t\t\tp.forget, donate(h, parts.Secondary))),", + "TestTabbingReachesTheControlsAndSaysInWhatOrder", + ), + ( + "a box to type in stands on Preferences while the menu guards still name it as holding none", + "internal/gui/window/preferences.go", + "\t\tparts.Prose(h.SettingsFolder()),", + "\t\tparts.Prose(h.SettingsFolder()), parts.NewEntry(),", + "TestAMenuIsNotDrawnAsWideAsABoxToTypeIn", + ), + ( + "a sentence of the registries is answered with its English in every language", + "internal/gui/text/registry.go", + "\treturn localise(key, english)", + "\treturn english", + "TestNoSentenceOfTheRegistriesReachesTheWindowUndisguised", + ), + ( + "a setting's sentence is drawn as the registry wrote it", + "internal/gui/parts/property.go", + 'if said := text.SettingDetail(owner, p.Name, p.Detail); said != "" {', + 'if said := p.Detail; said != "" {', + "TestNoSentenceOfTheRegistriesReachesTheWindowUndisguised", + ), + ( + "a preset's question is drawn as the registry wrote it", + "internal/gui/window/preset.go", + "parts.Prose(text.PresetQuestion(chosen.ID, chosen.Question))", + "parts.Prose(chosen.Question)", + "TestNoSentenceOfTheRegistriesReachesTheWindowUndisguised", + ), + ( + "what a preset finds is drawn as the registry wrote it", + "internal/gui/window/preset.go", + "parts.Bullets(text.PresetCatches(chosen.ID, chosen.Catches))", + "parts.Bullets(chosen.Catches)", + "TestNoSentenceOfTheRegistriesReachesTheWindowUndisguised", + ), + ( + "the English copy of the registries answers for them in an English window", + "internal/gui/text/catalogue.go", + 'path.Join(dir, RegistryFolder), English+".json")', + 'path.Join(dir, RegistryFolder), "")', + "TestTheEnglishCopyOfTheRegistriesNeverAnswersForThem", + ), + ( + "the words of the registries are never read into the language chosen", + "internal/gui/text/catalogue.go", + 'err := loadFiles(bundle, fsys, path.Join(dir, RegistryFolder), English+".json")', + "var err error", + "TestThePolishWindowSaysTheRegistriesInPolish", + ), + ( + "a size in the Polish window keeps the English decimal point", + "internal/gui/text/registry.go", + "return core.HumanBytesIn(n, DecimalMark())", + "return core.HumanBytes(n)", + "TestThePolishWindowSaysTheRegistriesInPolish", + ), + ( + "a preset's note stops saying which value it is about, so no language can find it", + "internal/preset/expansion.go", + "out = append(out, Note{About: name, Said: said})", + "out = append(out, Note{Said: said})", + "TestThePolishWindowSaysTheRegistriesInPolish", + ), + ( + "the window stops saying a setting's default where tfg formats says it", + "internal/gui/parts/property.go", + "return text.AllowedWithDefault(what, p.Default)", + "return what", + "TestTheWindowDescribesASettingAsTheCommandLineDoes", + ), + ( + "what a preset finds leaves the English copy of the registries", + "internal/gui/text/registrywords.go", + "\tfor i, line := range p.Catches {", + "\tfor i, line := range p.Catches[:0] {", + "TestTheRegistryCatalogueSaysWhatTheRegistriesSay", + ), + ( + "a Polish sentence of the registries translates an English one the registry no longer says", + "internal/gui/text/locale/registry/pl.json", + '"Detail.format/pdf.pages": { "hash": "sha256-298488d25c0b",', + '"Detail.format/pdf.pages": { "hash": "sha256-000000000000",', + "TestEveryLanguageSaysEverythingTheRegistriesSay", + ), + ( + "one English sentence of the registries is two Polish ones", + "internal/gui/text/locale/registry/pl.json", + '"Detail.format/gif.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz.', + '"Detail.format/gif.height": { "hash": "sha256-4b336c279584", "other": "Wysokość obrazu.', + "TestOneEnglishSentenceIsOneSentenceInEveryLanguage", + ), + ( + "a preset's question on the Polish site stops being the one the Polish window asks", + "web/content/pl/site.json", + '"question": "Czy mój formularz przesyłania plików przyjmuje to, co powinien, i odrzuca resztę?",', + '"question": "Czy mój formularz przesyłania plików działa?",', + "TestThePresetsSayInTheWindowWhatTheySayOnTheSite", + ), + ( + # A CRC-32 of another polynomial is eight hex digits too, and it is the + # one a person comparing with a ZIP or a PNG would not reproduce. + "crc32 is worked out with the Castagnoli polynomial instead of the IEEE one", + "internal/tool/checksum/checksum.go", + '\t{name: "crc32", digits: 8, make: func() hash.Hash { return crc32.NewIEEE() }},', + '\t{name: "crc32", digits: 8, make: func() hash.Hash { return crc32.New(crc32.MakeTable(crc32.Castagnoli)) }},', + "TestTheChecksumsAreTheKnownAnswers", + ), + ( + # sha512/256 is a SHA-512 as well, by name, and sha512sum disagrees. + "sha512 is worked out as the truncated sha512/256", + "internal/tool/checksum/checksum.go", + '\t{name: "sha512", digits: 128, make: sha512.New},', + '\t{name: "sha512", digits: 128, make: sha512.New512_256},', + "TestTheChecksumsAgreeWithTheSystemsOwnTools", + ), + ( + "the command line words a match one way and the window another", + "internal/tool/tool.go", + 'return fmt.Sprintf("Matches: the %s is %s, as expected.", v.About, v.Got)', + 'return fmt.Sprintf("Matches: the %s is %s.", v.About, v.Got)', + "TestTheWindowSaysAVerdictAsTheCommandLineDoes", + ), + ( + "the Tools tab draws what a tool works on and none of its settings", + "internal/gui/window/tools.go", + "\tfor _, o := range objects {\n\t\tt.work.Add(o)\n\t}", + "\tfor _, o := range objects {\n\t\t_ = o\n\t}", + "TestTheToolsScreenOffersEveryToolWithEveryBox", + ), + ( + "a tool run from the window finds the answer and never shows it", + "internal/gui/window/tools.go", + "\t\trows.Add(t.resultRow(row))", + "\t\t_ = row", + "TestTheWindowRunsAToolAndSaysWhatTheCommandLineSays", + ), + ( + "a checksum that does not match ends tfg tool with success", + "internal/cli/toolcmd.go", + "\tif failed {\n\t\treturn ExitVerify\n\t}", + "\tif failed {\n\t\treturn ExitOK\n\t}", + "TestToolsEndWithTheCodeOfWhatHappened", + ), + ( + # The flag package leaves a lone "-" where it is, so handing it back + # to the flags again is a loop that never ends - measured before the + # fix: killed by timeout, code 124. + "a lone dash among the paths of tfg tool is handed back to the flags for ever", + "internal/cli/toolcmd.go", + '\t\tfor len(args) > 0 && (args[0] == "-" || !strings.HasPrefix(args[0], "-")) {', + '\t\tfor len(args) > 0 && !strings.HasPrefix(args[0], "-") {', + "TestALoneDashIsAFileNameAndNotAHang", + ), + ( + "Copy on the Tools tab puts the name of the algorithm on the clipboard instead of the checksum", + "internal/gui/window/tools.go", + "\t\tt.host.Copy(value)", + "\t\tt.host.Copy(row[0])", + "TestTheToolsScreenTakesAChosenFileAndCopiesTheChecksum", + ), + ( + # A Fields of its own, which the window hands its shortcuts to and no + # box of the screen is in - the Tools tab alone loses them. + "the boxes of the Tools tab are not the ones the window hands its shortcuts to", + "internal/gui/window/tools.go", + "func (t *Tools) Fields() *parts.Fields { return t.fields }", + "func (t *Tools) Fields() *parts.Fields { return parts.NewFields() }", + "TestTheKeyboardRunsAToolFromInsideItsBox", + ), + ( + "the keyboard acts on the last screen with a directory rather than on the tab on show", + "internal/gui/window/open.go", + "\twireKeyboard(h, keyed, &looking)", + "\twireKeyboard(h, keyed, &showing)", + "TestTheKeyboardRunsAToolFromInsideItsBox", + ), + ( + # The one os.OpenFile outside the one claim is let through because + # its flags only read. A flag that writes has to stop being let + # through - read from the source, so a file built only off Windows + # is held on the Windows machine too. + "the checksum tool opens its file for writing as well, and the one claim does not notice", + "internal/tool/checksum/open_unix.go", + "os.O_RDONLY|syscall.O_NONBLOCK, 0)", + "os.O_RDWR|syscall.O_NONBLOCK, 0)", + "TestEveryFileThisToolWritesIsCreatedThroughOneClaim", + ), + ( + "a value declared long gets a box as narrow as a name again", + "internal/gui/parts/property.go", + "\t\t\tfield = Wide(field)\n", + "\t\t\t_ = field\n", + "TestALongValueTakesTheRowAndOnTheToolsTabNothingElseButAPathDoes", + ), + ( + "the checksum file separates checksum and path with one space, which sha256sum -c still reads", + "internal/tool/checksum/sums.go", + 'return sum + " " + name + "\\n"', + 'return sum + " " + name + "\\n"', + "TestTheChecksumFileIsTheOneSha256sumWrites", + ), + ( + "a tagged line is matched to the algorithm under its capitals, so SHA256 is an algorithm nobody knows", + "internal/tool/checksum/sums.go", + "algorithm = strings.ToLower(tag)", + "algorithm = tag", + "TestTheCheckReadsTheLinesOtherToolsWrite", + ), + ( + "the escaping of a name in a checksum file is kept rather than undone", + "internal/tool/checksum/sums.go", + "if path, ok = unescaped(path); !ok {", + "if ok = true; !ok {", + "TestTheCheckUndoesTheEscapingOfANameAndKeepsItInTheFolder", + ), + ( + "the mode character after the checksum is dropped only when it is a star, so a name after two spaces gains one", + "internal/tool/checksum/sums.go", + 'if strings.HasPrefix(path, " ") || strings.HasPrefix(path, "*") {', + 'if strings.HasPrefix(path, "*") {', + "FuzzChecksumFile", + ), + ( + "a setting of a tool refused by its declaration ends as a disk that would not cooperate", + "internal/tool/refusals.go", + "func (e *SettingError) Class() Class { return Asked }", + "func (e *SettingError) Class() Class { return Reading }", + "TestTheFolderToolsEndWithTheCodeOfWhatHappened", + ), + ( + "a file a stopped run left half written is listed in the checksum file as one of the folder's", + "internal/tool/checksum/write.go", + "case unfinished(path.Base(e.Path)):", + "case false && unfinished(path.Base(e.Path)):", + "TestAChecksumFileIsNeverWrittenOver", + ), + ( + "a write stopped in the middle writes down the checksums it had", + "internal/tool/checksum/write.go", + "\tif stopped != nil {\n\t\treturn nil, stopped\n\t}\n\tsums := make([]string, len(answers))", + "\tsums := make([]string, len(answers))", + "TestAStoppedWriteLeavesNothing", + ), + ( + "the Tools tab offers a file picker for a folder", + "internal/gui/window/tools.go", + "\tif kind == tool.Folder {\n", + "\tif false {\n", + "TestTheToolsTabWritesAndChecksAFolder", + ), + ( + "the Tools tab draws every item of a note, however many there are", + "internal/gui/window/tools.go", + "\t\tif i == parts.NoteItemsShown {\n", + "\t\tif false {\n", + "TestTheToolsTabCutsALongListShort", + ), + ( + "a pipe or a junction in a folder is read as a file", + "internal/tool/checksum/write.go", + "\t\tcase e.Kind != audit.Regular:\n", + "\t\tcase false:\n", + "TestAFolderToolNeverWaitsOnAPipeOrAJunction", + ), + ( + "the command line and the window word the verdict about a checksum file two ways", + "internal/tool/tool.go", + 'return fmt.Sprintf("Matches: every file %s lists is what it says.", v.About)', + 'return fmt.Sprintf("Matches: every file %s lists.", v.About)', + "TestTheWindowSaysAVerdictAsTheCommandLineDoes", + ), + ( + # Review of #158: the walk, the reading and the writing each followed + # the folder's name, so a link pointed elsewhere half way put the + # checksum file of one folder into another. + "checksum-write follows the folder's name again at every step", + "internal/tool/checksum/write.go", + "\tdir := followed(named)\n", + "\tdir := named\n", + "TestAFolderSwappedHalfWayGetsNoChecksumFileOfAnother", + ), + ( + # Review of #158: a number for every line that is not a checksum made a + # checksum file of 64 MiB of line breaks into two gigabytes. + "lines that are not checksums are kept one number a line again", + "internal/tool/checksum/sums.go", + "last >= 0 && s.NotSums[last].To == number-1 {", + "last >= 0 && s.NotSums[last].To == number-1 && false {", + "TestTheCheckReadsTheLinesOtherToolsWrite", + ), + ( + "the Tools tab stops laying itself out after a change of tool", + "internal/gui/window/toolsresult.go", + "\tt.view.scroll.Refresh()\n", + "", + "TestChoosingAToolLaysTheScreenOutAsIfItOpenedWithIt", + ), + ( + "the menu of tools loses the name each tool has on the command line", + "internal/gui/text/screens.go", + '"{{.Tool}} - {{.Question}}"', + '"{{.Question}}"', + "TestTheMenuOfToolsNamesEachToolAsTheCommandLineDoes", + ), + ( + "a verdict that matches is drawn in the grey of every other line", + "internal/gui/parts/verdict.go", + "\t\tlabel.Importance = widget.SuccessImportance\n", + "\t\tlabel.Importance = widget.MediumImportance\n", + "TestEveryScreenStillDrawsItsStoredPicture", + ), + ( + "Copy all copies a long list cut short the way the screen draws it", + "internal/gui/window/toolstext.go", + "lines = append(lines, noteLines(d, n, 0)...)", + "lines = append(lines, noteLines(d, n, 20)...)", + "TestCopyAllPutsTheWholeResultOnTheClipboard", + ), + ( + "Open folder never learns where the run worked", + "internal/gui/window/tools.go", + "\tt.offer.folder = ranIn(t.chosen, in)\n", + "", + "TestOpenFolderLeadsToTheFolderTheToolWorkedIn", + ), + ( + "Open folder leads to the file a tool was given rather than its folder", + "internal/gui/window/toolstext.go", + "\t\tfolder = filepath.Dir(named)\n", + "\t\tfolder = named\n", + "TestOpenFolderLeadsToTheFolderTheToolWorkedIn", + ), + ( + "Open folder outlives a change of tool", + "internal/gui/window/tools.go", + "\tt.chosen = d\n\tt.offer.withdraw(t.busy.relay)\n", + "\tt.chosen = d\n", + "TestOpenFolderLeadsToTheFolderTheToolWorkedIn", + ), + ( + "a language leaves out a word the toolkit asks for", + "internal/gui/text/locale/toolkit/pl.json", + " \"Cancel\": { \"hash\": \"sha256-19766ed6ccb2\", \"other\": \"Anuluj\" },\n", + "", + "TestEveryLanguageGivesTheToolkitEveryWord", + ), + ( + "the toolkit's words lose one it asks for", + "internal/gui/text/locale/toolkit/en.json", + " \"Cancel\": { \"description\": \"A word of the graphics toolkit the window is built with - in its menus and dialogs. The window gives the toolkit these in the language it speaks.\", \"hash\": \"sha256-19766ed6ccb2\", \"other\": \"Cancel\" },\n", + "", + "TestTheToolkitIsGivenEveryWordItAsksFor", + ), + ( + "a Polish engine sentence names a value the code does not give", + "internal/gui/text/locale/said/pl.json", + "{{.Setting}} {{.Value}} nie ma liczby", + "{{.Setting}} {{.Wartosc}} nie ma liczby", + "TestEveryLanguageSaysEverythingTheEngineSays", + ), + ( + "two values of one list share a Polish name", + "internal/gui/text/locale/registry/pl.json", + "\"Choice.orientation.landscape\": { \"hash\": \"sha256-acb955df82b8\", \"other\": \"poziomo\" }", + "\"Choice.orientation.landscape\": { \"hash\": \"sha256-acb955df82b8\", \"other\": \"pionowo\" }", + "TestEveryListSaysEachValueOnceInEveryLanguage", + ), + ( + "an engine sentence names a value by the name the setting slot keeps", + "internal/core/size.go", + "has no number: write something like 10mb or 1048576\", A(\"Value\", s))", + "has no number: write something like 10mb or 1048576\", A(\"Setting\", s))", + "TestEverySentenceTheEngineSaysCanBeTranslated", + ), + ( + "the code says an engine sentence the English copy does not hold", + "internal/core/size.go", + "{setting} %q has no number: write something like 10mb or 1048576", + "{setting} %q has no number: write a number like 10mb or 1048576", + "TestTheEnglishCopyOfTheEngineSentencesSaysWhatTheCodeSays", + ), + ( + "a tool refusal is said again as bare text", + "internal/tool/refusals.go", + "func (e *SettingError) Unwrap() error { return e.Err }", + "func (e *SettingError) Unwrap() error { return errors.New(e.Err.Error()) }", + "TestNoRefusalOfTheEngineIsWrittenAsBareText", + ), + ( + "the pseudo language says the engine's sentences around the catalogue", + "internal/gui/text/said.go", + "\tif pseudo {\n\t\treturn fill(pseudoOf(core.NamedLayout(", + "\tif false && pseudo {\n\t\treturn fill(pseudoOf(core.NamedLayout(", + "TestNoSentenceOfTheEngineReachesTheWindowUndisguised", + ), + ( + "a Polish description names a list value in English", + "internal/gui/text/locale/registry/pl.json", + "Mieszana zmienia je na przemian", + "Mixed zmienia je na przemian", + "TestNoTranslationNamesAListValueTheListCallsOtherwise", + ), + ( + "the closed box of a list draws the value rather than its name", + "internal/gui/parts/menulook.go", + "\tif m.menu.ShownAs == nil || m.menu.Selected == \"\" {", + "\tif true || m.menu.ShownAs == nil || m.menu.Selected == \"\" {", + "TestTheEnginesRefusalsReachThePolishWindowInPolish", + ), + ( + "a refusal wrapping nothing spreads into no refusal and marks no box", + "internal/core/refusal.go", + "func (r *Refusal) Unwrap() error {\n\tif wrapped := r.said.wrapped(); len(wrapped) > 0 {\n\t\treturn wrapped[0]\n\t}\n\treturn nil\n}", + "func (r *Refusal) Unwrap() []error { return r.said.wrapped() }", + "TestTheEnginesRefusalsReachThePolishWindowInPolish", + ), + ( + "the English licence notice loses the gap between its paragraphs", + "internal/gui/text/registry.go", + "\treturn strings.Join(out, \"\\n\\n\")\n", + "\treturn strings.Join(out, \"\\n\")\n", + "TestTheEnglishWindowSaysTimeAndTheLicenceAsTheCommandLineDoes", + ), + ( + "a preset title in the window drifts from the site's", + "internal/gui/text/locale/registry/pl.json", + "\"Title.size-boundaries\": { \"hash\": \"sha256-21dd0ecec76d\", \"other\": \"Granice rozmiaru\" }", + "\"Title.size-boundaries\": { \"hash\": \"sha256-21dd0ecec76d\", \"other\": \"Granice rozmiarów\" }", + "TestThePresetTitlesInTheWindowAreTheSites", + ), + # --- the window's WinGet package installs the Windows installer first (2026-10-06) --- + ( + "the command line's WinGet package reaches tfg through a link", + "packaging/winget/installer.cli.yaml.in", + "ArchiveBinariesDependOnPath: true", + "ArchiveBinariesDependOnPath: false", + "TestEveryWingetPackageKeepsItsProgramBesideItsFiles", + ), + ( + "the window's Windows installer is declared for one account instead of the machine", + "packaging/winget/installer.window.yaml.in", + " Scope: machine", + " Scope: user", + "TestTheWindowInstallsWithAStartMenuShortcutWhereWinGetCan", + ), + ( + "WinGet looks for the installed window under an UpgradeCode without its braces", + ".github/scripts/build_packages.py", + '"UPGRADE_CODE": "{%s}" % upgrade_code,', + '"UPGRADE_CODE": "%s" % upgrade_code,', + "TestTheWindowInstallsWithAStartMenuShortcutWhereWinGetCan", + ), + ( + "the window's Windows installer carries its checksum in small letters", + ".github/scripts/build_packages.py", + '"MSI_SHA256": sums[msi].upper(),', + '"MSI_SHA256": sums[msi],', + "TestEachWingetPackageOffersTheArchitecturesTheReleaseBuilds", + ), + ( + "a WinGet render without the installer's line is not refused by name", + ".github/scripts/build_packages.py", + ' if "winget" in feeds:', + " if False:", + "TestTheRendererRefusesInputsThatLookFineAndAreWrong", + ), + ( + "a render of one feed writes the other feed as well", + ".github/scripts/build_packages.py", + " for feed in feeds:", + " for feed in FEEDS:", + "TestAReleaseWithoutTheInstallerStillRendersForChocolatey", + ), + ( + "the window's Chocolatey install takes over the Windows installer's shortcut", + "packaging/chocolatey/tools/chocolateyinstall.window.ps1.in", + "if ($keep) {", + "if ($false) {", + "TestTheWindowPackageStartsWhereAPersonCanWrite", + ), + # --- review of #164 (2026-10-06) --- + ( + "the window's Chocolatey install takes over a shortcut that points at no file", + "packaging/chocolatey/tools/chocolateyinstall.window.ps1.in", + " if (-not $target) {", + " if ($false) {", + "TestTheWindowPackageStartsWhereAPersonCanWrite", + ), + # --- a length of time, the kind a video's duration is written in + # (2026-10-06, docs/WIDEO-2026-10-06.md section 12) --- + ( + "a minute counts as a second", + "internal/core/duration.go", + "return 1, 60_000, true", + "return 1, 1_000, true", + "TestDurationsCountInWholeMilliseconds", + ), + ( + "a length that is not a whole millisecond is rounded down instead of refused", + "internal/core/duration.go", + "if f*per%scale != 0 {", + "if f*per%scale != 0 && false {", + "TestADurationThatIsNotAWholeMillisecondIsRefused", + ), + ( + "units written twice or out of order are added up", + "internal/core/duration.go", + "if index <= previous {", + "if index <= previous && false {", + "TestNonsenseDurationsAreRefusedWithTheReason", + ), + ( + "a written length drops the leading zeros of its milliseconds", + "internal/core/duration.go", + 'strings.TrimRight(strconv.FormatInt(1000+rest%1000, 10)[1:], "0")', + 'strings.TrimRight(strconv.FormatInt(rest%1000, 10), "0")', + "TestAWrittenDurationReadsBackAsTheSameMilliseconds", + ), + ( + "a duration setting takes a length outside its range", + "internal/format/format.go", + "if ms < p.Min || ms > p.Max {", + "if ms < p.Min && ms > p.Max {", + "TestADurationSettingTakesALengthInsideItsRangeAndSaysSo", + ), + ( + "a fraction of a unit is divided before it is multiplied, so 0.025h comes to nothing", + "internal/core/duration.go", + "part := f * per / scale", + "part := f / scale * per", + "TestEveryWayOfWritingOneLengthGivesTheSameMilliseconds", + ), + # --- the first film format, WebM (2026-10-06, docs/WIDEO-2026-10-06.md + # section 13) --- + ( + "every frame after the first shows the key frame again instead of its copy", + "internal/format/video/av1.go", + "w.bits(copySlot, 3)", + "w.bits(0, 3)", + "TestAFilmNeverShowsAKeyFrameASecondTime", + ), + ( + "the hidden copy is written as not showable, so showing it is refused", + "internal/format/video/av1.go", + "w.bit(1) // showable_frame", + "w.bit(0) // showable_frame", + "TestEveryFrameOfAFilmSurvivesItsReferenceTool", + ), + ( + "the container says the film lasts a millisecond longer than it does", + "internal/format/webm/layout.go", + "appendFloat(b, idDuration, float64(s.DurationMs))", + "appendFloat(b, idDuration, float64(s.DurationMs+1))", + "TestAFilmHoldsTheFramesTheManifestDeclares", + ), + ( + "level 4.0 admits a lower display rate than the specification gives it", + "internal/format/video/level.go", + "{8, 2359296, 6144, 3456, 70_778_880, 12_000, 4},", + "{8, 2359296, 6144, 3456, 60_000_000, 12_000, 4},", + "TestAFilmDeclaresTheLowestLevelTheSpecificationAllows", + ), + ( + "a length that does not end on a frame is cut to the frame before it", + "internal/format/video/timeline.go", + "if ms*int64(fps)%1000 == 0 && ms > 0 {", + "if ms > 0 {", + "TestALengthThatDoesNotEndOnAFrameIsRefusedWithTheTwoNearest", + ), + ( + "a film's track declares its width as its height, and a player shapes every frame wrongly", + "internal/format/webm/layout.go", + " v = appendUint(v, idPixelHeight, uint64(s.Height))", + " v = appendUint(v, idPixelHeight, uint64(s.Width))", + "TestAFilmHoldsTheFramesTheManifestDeclares", + ), + ( + # Every picture looks like the first, so the film is coded once and + # shown for its whole length - the photograph the owner saw. + "a film's pictures all look like its first, so the picture never moves", + "internal/format/video/pictures.go", + " l := p.crew.film.lookOf(p.next)", + " l := p.crew.film.lookOf(0)", + "TestAFilmPictureChangesWhereTheManifestSays", + ), + ( + # A change opens with the picture shown again instead of the new one, + # and the file stays well formed in both containers - only the decoded + # frames show the picture standing still. + "a change of picture is written as the old picture shown again", + "internal/format/video/timeline.go", + " case t.StartsChange(i), t.IsKey(i - 1):", + " case t.IsKey(i - 1):", + "TestAFilmPictureChangesWhereTheManifestSays", + ), + ( + # The reserve falls back to a tenth above the first picture, which a + # small film's later pictures pass by up to 42 percent. + "a film of a named size reserves a tenth above its first picture instead of above its sample", + "internal/format/video/choose.go", + " for _, tiles := range pictures {", + " for _, tiles := range pictures[:1] {", + "TestAFilmOfANamedSizeKeepsEveryPictureInsideItsReserve", + ), + ( + # Back to the model that counted two pictures a second whatever the + # film, which declares too low a level for a film coded every frame. + "the level's one second buffer counts two pictures whatever the film carries", + "internal/format/video/level.go", + " if int64(perSecond)*8*int64(frameBytes) > l.mainKbps*1000 {", + " if 2*8*int64(frameBytes) > l.mainKbps*1000 {", + "TestAFilmDeclaresTheLowestLevelTheSpecificationAllows", + ), + ( + # One object per frame, the defect the film's own allocation ceiling + # sits below: measured at 698 objects against a ceiling of 512. + "a film allocates a block header for every frame it writes", + "internal/format/webm/webm.go", + " out.write(block[:])", + " out.write(append([]byte(nil), block[:]...))", + "TestNoGeneratorHoldsTheWholeFileInMemory", + ), + ( + # A painter that forgets the square in its strips paints a different + # picture from the whole painter, which draws it. + "a film's strip painter leaves the square out", + "internal/format/video/picture.go", + " draw.Draw(s, image.Rect(l.x, f.squareY, l.x+f.side, f.squareY+f.side), f.square, image.Point{}, draw.Src)", + " _ = f.square", + "TestAFilmPaintsOnlyWhatChangesAndGetsTheWholePicture", + ), + ( + # A helper that codes a picture other than the one it was given makes + # a film whose bytes differ from the film of one goroutine. + "a helper codes its picture with the square at the start", + "internal/format/video/ahead.go", + " c.code(p, j, true)", + " j.look.x = 0\n c.code(p, j, true)", + "TestAFilmCodedBySeveralGoroutinesHasTheBytesOfOne", + ), + ( + # A crew that never takes a helper leaves the comparison a film with + # itself - the guard has to say nothing was coded beside. + "a film never takes a helper", + "internal/format/video/ahead.go", + " if most := min(int64(runtime.GOMAXPROCS(0)-1), jobs-1); most > 0 {", + " if most := int64(0); most > 0 {", + "TestAFilmCodedBySeveralGoroutinesHasTheBytesOfOne", + ), + ( + # A film written without closing its pictures leaves its helpers + # waiting on a queue nobody will close. + "a written film does not close its pictures", + "internal/format/webm/webm.go", + " defer pics.Close()", + " _ = pics.Close", + "TestAFilmStoppedHalfWayLeavesNoHelperRunning", + ), + ( + # A film that never reports its pictures' work leaves the bar counting + # the padding as the film - five percent for a minute, then all of it. + "a film's clusters never report the work of their pictures", + "internal/format/webm/webm.go", + " format.Worked(ctx, pics.Work())", + " _ = pics.Work()", + "TestAFilmsProgressMovesWithItsPicturesNotItsPadding", + ), + ( + # The command line works its percentage out of the bytes again, the + # way a film's bar stood at five percent for its whole minute. + "the command line's bar takes its percentage from the bytes", + "internal/cli/progress.go", + " pr.Percent(),", + " int(100*pr.BytesDone/max(pr.BytesTotal, 1)),", + "TestTheBarAndTheTimeLeftComeFromTheWorkOnBothSurfaces", + ), + ( + # Progress gives its percentage by the bytes, which both bars draw. + "a report's percentage counts the bytes rather than the work", + "internal/engine/progress.go", + "func (p Progress) Percent() int { return core.Percent(p.WorkDone, p.WorkTotal) }", + "func (p Progress) Percent() int { return core.Percent(p.BytesDone, p.BytesTotal) }", + "TestTheBarAndTheTimeLeftComeFromTheWorkOnBothSurfaces", + ), + ( + # The writer swallows a helper's panic and goes on with an empty + # picture, where the run should end in an error. + "the writer swallows the panic a helper kept", + "internal/format/video/ahead.go", + " panic(j.panicked)", + " return Coded{}, nil", + "TestAPanicOnAHelperComesOutOnTheGoroutineThatAskedForIt", + ), + ( + # A tile's key leaves out the clock's characters, so a tile of the + # clock from an earlier picture is taken for a later one - the film + # decodes, and its clock reads wrong. + "a tile's key leaves out the clock", + "internal/format/video/tilekey.go", + " for c := p.chars[0]; c < p.chars[1]; c++ {", + " for c := p.chars[0]; c < p.chars[0]; c++ {", + "TestEveryTileOfAFilmIsGav1dsCodingOfItsPixelsAndDecodesAsItDoesAlone", + ), + ( + # tile_info says a row of tiles one superblock taller than it is, so + # every tile after it starts in the wrong place. + "tile_info says a row of tiles taller than it is", + "internal/format/video/grid.go", + " w.ns(s-1, min(b.sbRows-start, max(b.maxArea()/widest, 1))) // height_in_sbs_minus_1", + " w.ns(min(s, min(b.sbRows-start, max(b.maxArea()/widest, 1))-1), min(b.sbRows-start, max(b.maxArea()/widest, 1))) // height_in_sbs_minus_1", + "TestEveryTileOfAFilmIsGav1dsCodingOfItsPixelsAndDecodesAsItDoesAlone", + ), + ( + # A tile is read from the picture's planes with its own width as the + # stride, so gav1d codes pixels from the wrong rows. + "a tile is read with its own width as the stride", + "internal/format/video/still.go", + "Src: p.Y[luma:], SrcStride: p.Width,", + "Src: p.Y[luma:], SrcStride: r.Dx(),", + "TestEveryTileOfAFilmIsGav1dsCodingOfItsPixelsAndDecodesAsItDoesAlone", + ), + ( + # Every film is one tile again, so the guards about films of tiles + # ask about films that have none - each has to say so. + "every film is one tile, asked of the reference tool's guard", + "internal/format/video/grid.go", + " break\n }\n return out\n", + " break\n }\n break\n", + "TestEveryFrameOfAFilmSurvivesItsReferenceTool", + ), + ( + "every film is one tile, asked of the guard on where the picture changes", + "internal/format/video/grid.go", + " break\n }\n return out\n", + " break\n }\n break\n", + "TestAFilmPictureChangesWhereTheManifestSays", + ), + ( + "every film is one tile, asked of the guard on a named size's reserve", + "internal/format/video/grid.go", + " break\n }\n return out\n", + " break\n }\n break\n", + "TestAFilmOfANamedSizeKeepsEveryPictureInsideItsReserve", + ), + ( + "every film is one tile, asked of the guard on helpers", + "internal/format/video/grid.go", + " break\n }\n return out\n", + " break\n }\n break\n", + "TestAFilmCodedBySeveralGoroutinesHasTheBytesOfOne", + ), + ( + # A picture wider than 4096 and only a superblock and a little tall: + # without the narrowing its last row of tiles is one superblock + # holding a pixel or two, which Annex A refuses (81 900 layouts of the + # sweep on 2026-10-07, docs/WEBM-LIMIT-2026-10-07.md section 2). + "a film's columns are no longer narrowed so a row of tiles can be two superblocks, and the last row of a wide low picture is under 8 pixels", + "internal/format/video/grid.go", + "\t\twidest = min(widest, max(area/2, 1))\n", + "\t\t_ = area\n", + "TestEveryFilmLayoutIsOneTheAV1SpecificationAllows", + ), + ( + "a film's column may be one superblock wider than MAX_TILE_WIDTH", + "internal/format/video/grid.go", + "\twidest := maxTileWidth / superblock\n", + "\twidest := maxTileWidth/superblock + 1\n", + "TestEveryFilmLayoutIsOneTheAV1SpecificationAllows", + ), + ( + "a film's row of tiles may be twice as tall as tile_info can code", + "internal/format/video/grid.go", + "\tg.rows = cutEach(g.rows, max(area/slices.Max(g.cols), 1))\n", + "\tg.rows = cutEach(g.rows, 2*max(area/slices.Max(g.cols), 1))\n", + "TestEveryFilmLayoutIsOneTheAV1SpecificationAllows", + ), + ( + # The order of the parts is in the bytes of every film cut this way. + "a film's cut parts put the larger ones first, and the bytes of a wide film move", + "internal/format/video/grid.go", + "\t\t\tout = append(out, s/n+(k+s%n)/n)\n", + "\t\t\tout = append(out, s/n+(n-1-k+s%n)/n)\n", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "a film picture one pixel over the largest an AV1 level describes is no longer refused", + "internal/format/video/settings.go", + "\tmaxPixels = 8192 * 4352\n", + "\tmaxPixels = 8192*4352 + 4352\n", + "TestAFilmPictureLargerThanAnyAV1LevelIsRefusedNotBroken", + ), + ( + "a film picture wider than 4096 is refused again", + "internal/format/video/settings.go", + "\tmaxWidth = 16384\n", + "\tmaxWidth = 4096\n", + "TestAFilmPictureLargerThanAnyAV1LevelIsRefusedNotBroken", + ), + ( + # A tile the clock or the square crosses is coded from the film's own + # planes, which hold the gradient alone - the film decodes, and shows + # neither. + "a film's changing tiles are taken from the gradient, without the clock and the square", + "internal/format/video/picture.go", + "\tif !p.f.changes(r) {\n", + "\tif true {\n", + "TestAFilmPaintsOnlyWhatChangesAndGetsTheWholePicture", + ), + ( + "a film's tile copies its chroma from the left of the picture rather than from under the tile", + "internal/format/video/picture.go", + "\t\td, s := (at.Y/2+cy-r.Min.Y/2)*dcw+at.X/2, cy*scw+r.Min.X/2\n", + "\t\td, s := (at.Y/2+cy-r.Min.Y/2)*dcw+at.X/2, cy*scw\n", + "TestAFilmPaintsOnlyWhatChangesAndGetsTheWholePicture", + ), + ( + "a film's tile converts its luma into the wrong columns", + "internal/format/video/picture.go", + "\t\t\tp.Y[(y-at.Y)*w+x-at.X] = uint8(16 + (47*red+157*g+16*b+128)>>8)\n", + "\t\t\tp.Y[(y-at.Y)*w+x-at.X/2] = uint8(16 + (47*red+157*g+16*b+128)>>8)\n", + "TestEveryTileOfAFilmIsGav1dsCodingOfItsPixelsAndDecodesAsItDoesAlone", + ), + ( + # The rows of a tile outside its strips keep what the painter's last + # tile left there rather than the gradient. + "a film's painted tile does not start from the film's planes", + "internal/format/video/picture.go", + "\tcopyRect(out, image.Point{}, f.basePlanes, r)\n", + "\t_ = copyRect\n", + "TestAFilmPaintsOnlyWhatChangesAndGetsTheWholePicture", + ), + ( + # The label's last row comes out as the gradient under it - the film + # made a band at a time differs from the picture painted whole. + "a film's ground leaves the last row of the label's band to the gradient", + "internal/format/video/picture.go", + "\t\tif y < g.label.Rect.Max.Y {\n", + "\t\tif y < g.label.Rect.Max.Y-1 {\n", + "TestAFilmPaintsOnlyWhatChangesAndGetsTheWholePicture", + ), + ( + "a film's ground leaves the last two rows of every band of sixty four unconverted", + "internal/format/video/picture.go", + "\t\tr := image.Rect(0, y, p.Width, min(p.Height, y+superblock))\n", + "\t\tr := image.Rect(0, y, p.Width, min(p.Height, y+superblock-2))\n", + "TestAFilmPaintsOnlyWhatChangesAndGetsTheWholePicture", + ), + ( + # The codec configuration keeps its four bytes and loses the sequence + # header, which every key sample still carries - so libaom plays the + # film, and only the comparison with the WebM sees the difference. + "an MP4's codec configuration leaves out the sequence header", + "internal/format/mp4/layout.go", + " e = append(e, boxEnd(append(boxStart(\"av1C\"), s.Config()...))...)", + " e = append(e, boxEnd(append(boxStart(\"av1C\"), s.Config()[:4]...))...)", + "TestAnMP4CarriesTheFramesOfTheWebMOfTheSameRequest", + ), + ( + "an MP4 lists the frame after each key frame as the one to start from", + "internal/format/mp4/mp4.go", + " o.u32(uint32(k*t.KeyEvery + 1))", + " o.u32(uint32(k*t.KeyEvery + 2))", + "TestAFilmHoldsTheFramesTheManifestDeclares", + ), + ( + # Every chunk a byte late: the tables still add up, and every sample + # read from them is the wrong bytes. + "an MP4 places every chunk a byte after its samples", + "internal/format/mp4/mp4.go", + " o.u32(uint32(front + content))", + " o.u32(uint32(front + content + 1))", + "TestAFilmNeverShowsAKeyFrameASecondTime", + ), + ( + "an MP4's media header says the film is a frame longer", + "internal/format/mp4/layout.go", + " b = appendU32s(b, 0, 0, uint32(s.FPS), uint32(s.Frames))", + " b = appendU32s(b, 0, 0, uint32(s.FPS), uint32(s.Frames+1))", + "TestAFilmHoldsTheFramesTheManifestDeclares", + ), + ( + "an MP4's track declares its width as its height", + "internal/format/mp4/layout.go", + " return boxEnd(appendU32s(b, uint32(s.Width)<<16, uint32(s.Height)<<16))", + " return boxEnd(appendU32s(b, uint32(s.Height)<<16, uint32(s.Height)<<16))", + "TestAFilmHoldsTheFramesTheManifestDeclares", + ), + ( + # The same bytes in twice the time, which only counting the tiles + # coded can see. + "an MP4's second pass takes none of the tiles its first pass kept", + "internal/format/video/pictures.go", + " known: p.known, kept: p.kept, seen: p.seen,", + " known: map[tileKey]*job{}, kept: p.kept, seen: p.seen,", + "TestAnMP4CodesNoTileItsFirstPassKept", + ), + ( + "an MP4's first pass never reports the work of its pictures", + "internal/format/mp4/mp4.go", + " format.Worked(ctx, first.Work())", + " _ = first.Work()", + "TestAFilmsProgressMovesWithItsPicturesNotItsPadding", + ), + ( + "a written MP4 does not close the pictures of its second pass", + "internal/format/mp4/mp4.go", + " defer pics.Close()", + " _ = pics.Close", + "TestAFilmStoppedHalfWayLeavesNoHelperRunning", + ), + ( + "an MP4 stopped in its first pass does not close its pictures", + "internal/format/mp4/mp4.go", + " defer first.Close()", + " _ = first.Close", + "TestAFilmStoppedHalfWayLeavesNoHelperRunning", + ), + ( + "an MP4 carries its padding in a box that is not free", + "internal/format/isobmff/isobmff.go", + " if err := WriteBoxHeader(w, \"free\", payload); err != nil {", + " if err := WriteBoxHeader(w, \"skip\", payload); err != nil {", + "TestAFilmHoldsTheFramesTheManifestDeclares", + ), + ( + "an MP4's chunk runs say each chunk holds a sample more", + "internal/format/mp4/mp4.go", + " o.u32(uint32(samples))", + " o.u32(uint32(samples + 1))", + "TestAFilmHoldsTheFramesTheManifestDeclares", + ), + ( + # sdtp is read by no decoder here, so only the structural check, which + # reads every sample's frames against it, can see a wrong one. + "an MP4 says a key frame depends on other samples", + "internal/format/mp4/mp4.go", + " dependsOnNone = 2 << 4", + " dependsOnNone = 1 << 4", + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + # One object per frame, the defect the film's own allocation ceiling + # sits below: measured at 1241 objects against a ceiling of 1024. + "an MP4 allocates a copy of every frame it writes", + "internal/format/mp4/mp4.go", + " o.write(frame[t.SampleAt(i)])", + " o.write(append([]byte(nil), frame[t.SampleAt(i)]...))", + "TestNoGeneratorHoldsTheWholeFileInMemory", + ), + ( + # Without the step down, padding a byte past a full box leaves a + # second box of one byte, which no box can be. + "free boxes past two gigabytes leave a leftover too short to be a box", + "internal/format/isobmff/isobmff.go", + " payload -= BoxHeader", + " payload -= 0", + "TestFreeBoxesCarryPaddingOfAnyLength", + ), + ( + # Without the cap the remainder of a one-feature file goes into the note + # whole, and past ten million characters GDAL refuses the file. The guard + # asks the checker how many spaces it found and refuses nought. + "a geojson note is never capped, so spaces never take the remainder", + "internal/format/geojson/feature.go", + "\tnote := min(fill, noteCap)", + "\tnote := fill", + "TestAGeoJSONNotePastAMebibyteBecomesSpacesAndSurvivesItsReferenceTool", + ), + ( + "a geojson feature number is not handed back, so the ids skip", + "internal/format/geojson/feature.go", + "\tr.next--\n\tr.seen = r.before\n", + "\tr.seen = r.before\n", + "TestRecordNumbersRunFromOneWithoutAGap", + ), + ( + # Every kind the same: the right size, valid GeoJSON, and a mixed file + # that is points from end to end. + "geojson mixed draws one kind for every feature", + "internal/format/geojson/settings.go", + "\treturn s.kinds[(n-1)%int64(len(s.kinds))]", + "\treturn s.kinds[0]", + "TestEveryFormatSurvivesItsReferenceTool", + ), + ( + # A closing feature measured a byte short per point than the one + # written, at every vertex count above three. + "the smallest geojson feature is carried forward with the wrong step", + "internal/format/geojson/feature.go", + "\t\tlongest = max(longest, three+int64(r.s.vertices-minVertices)*(four-three))", + "\t\tlongest = max(longest, three+int64(r.s.vertices-minVertices)*(four-three-1))", + "TestTheSmallestGeoJSONFeatureCarriedForwardIsTheOneMeasuredWhole", + ), + ( + "geojson winding=reversed writes outlines counter-clockwise all the same", + "internal/format/geojson/geometry.go", + "\tr.run(from, outline, r.s.reversed)", + "\tr.run(from, outline, false)", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + # The longitude past 180 is written as it was drawn, 180.5 rather than + # -179.5 - a position outside the range RFC 7946 allows. + "a geojson shape across the antimeridian is never wrapped", + "internal/format/geojson/shape.go", + "\tif x > g.lon {", + "\tif x > g.lon && false {", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + # .005 written as .5 - fewer places than ordered, and a different value. + "geojson coordinates lose the leading zeros of their fraction", + "internal/format/geojson/layout.go", + "\tfor lead := scale / 10; lead > frac && lead > 1; lead /= 10 {", + "\tfor lead := scale / 10; lead > frac && lead > 1 && false; lead /= 10 {", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + # An outline that does not end where it starts, one position short. + "a geojson outline is never closed", + "internal/format/geojson/shape.go", + "\td.reverse(from)\n\td.pos = append(d.pos, d.pos[first:first+d.stride]...)\n}\n\n// half", + "\td.reverse(from)\n\td.pos = append(d.pos, d.pos[first:first]...)\n}\n\n// half", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + # Both halves of an outline above its middle, so the way back can cross + # the way out - the shape shapely is there to judge. + "the bottom of a geojson outline is drawn above its middle", + "internal/format/geojson/shape.go", + "\td.half(b, bottom, c-1, -1)", + "\td.half(b, bottom, c+1, 1)", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + "geojson offers a line one point more than the globe has room for", + "internal/format/geojson/shape.go", + "\t\treturn g.room() + 1", + "\t\treturn g.room() + 2", + "TestGeoJSONRefusesMorePointsThanTheGlobeHasRoomFor", + ), + ( + # The feature built only to learn it does not fit still widens the box + # of the collection, so the box takes in a place the file does not hold. + # Reddened by hand on 2026-10-07: the bbox case at its smallest size. + "a geojson feature thrown away still widens the collection box", + "internal/format/geojson/feature.go", + "\tr.next--\n\tr.seen = r.before\n", + "\tr.next--\n", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + # Holes wound like their outline - GDAL and shapely take both, so only + # the checker's signed area sees it. + "geojson holes run the same way round as their outline", + "internal/format/geojson/geometry.go", + "\t\tr.run(at, at+holePositions, !r.s.reversed)", + "\t\tr.run(at, at+holePositions, r.s.reversed)", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + # The box of a shape across the antimeridian written the naive way, from + # the smaller written longitude to the larger - a box round the other + # side of the globe. + "a geojson bbox across the antimeridian is written the naive way round", + "internal/format/geojson/geometry.go", + "\twest, east := g.wrap(x.lo[0]), g.wrap(x.hi[0])", + "\twest, east := min(g.wrap(x.lo[0]), g.wrap(x.hi[0])), max(g.wrap(x.lo[0]), g.wrap(x.hi[0]))", + "TestAGeoJSONBoxAcrossTheAntimeridianOrRoundTheGlobeSurvivesItsReferenceTool", + ), + ( + "a geojson bbox reaching round the globe is not the whole circle", + "internal/format/geojson/geometry.go", + "\tif x.hi[0]-x.lo[0] >= 2*g.lon {", + "\tif x.hi[0]-x.lo[0] >= 2*g.lon && false {", + "TestAGeoJSONBoxAcrossTheAntimeridianOrRoundTheGlobeSurvivesItsReferenceTool", + ), + ( + "geojson offers one hole more than half the globe has room for", + "internal/format/geojson/shape.go", + "func mostHoles(g grid) int64 { return (g.lon - 2) / holeCell }", + "func mostHoles(g grid) int64 { return (g.lon + 2) / holeCell }", + "TestGeoJSONRefusesHolesTheOutlineHasNoRoomFor", + ), + ( + "geojson takes holes in an outline of five points", + "internal/format/geojson/geojson.go", + "s.vertices < minVerticesWithHoles || ", + "", + "TestGeoJSONRefusesHolesTheOutlineHasNoRoomFor", + ), + ( + # Holes a step from each other and from the outline. At fifteen places a + # step is less than a double tells apart, and GEOS read such holes as + # touching and the polygon as cut in two (measured 2026-10-07). + "geojson holes keep a step clear rather than a share of their cell", + "internal/format/geojson/shape.go", + "\tmargin := max(1, cell/holeCell)", + "\tmargin := int64(1)", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + "geojson unlocated=some takes no place from any feature", + "internal/format/geojson/settings.go", + "\tif s.unlocated == Some && n%unlocatedEvery == 0 {", + "\tif s.unlocated == Some && n%unlocatedEvery == 0 && false {", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + "geojson ids=mixed writes every id as a number", + "internal/format/geojson/settings.go", + "\t\tif n%2 == 0 {\n\t\t\treturn String", + "\t\tif n%2 == 0 && false {\n\t\t\treturn String", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + # The closing feature measured without the box of the collection, so a + # file at its smallest size cannot be reached. + "the smallest geojson feature leaves out the collection's bbox", + "internal/format/geojson/feature.go", + "\tclosing := r.epilogue(nil, widestBox(r.s, r.g))", + "\tclosing := r.epilogue(nil, extent{})", + "TestEveryGeoJSONSettingSurvivesItsReferenceTool", + ), + ( + # The odd byte alternative cannot split goes into the HTML as a letter + # rather than a space: the size holds, and the HTML says a word the + # text part does not. + "eml alternative puts its odd byte in the html as a letter", + "internal/format/eml/message.go", + "\t\to.put(\" \")\n", + "\t\to.put(\"x\")\n", + "TestAlternativePutsTheOddByteInTheHTML", + ), + ( + # The label is left out of a message too small for it, and the note + # says something else: the size holds and label_embedded is right. + "eml leaves out its label under a code nothing reads", + "internal/format/eml/eml.go", + "\t\tCode: \"label_omitted\",\n", + "\t\tCode: \"label_dropped\",\n", + "TestAnEMLTooSmallForItsLabelSaysItCarriesNone", + ), + ( + # rfc2231 writes the name in Content-Type as well - counted by the code + # that writes it, so the size holds, and every reader still finds the + # name. Only the structural check knows where it was ordered to be. + "eml rfc2231 also writes the name where content-type puts it", + "internal/format/eml/attach.go", + "\tcase RFC2231:\n\t\tinDisposition = rfc2231Params(s, name)\n", + "\tcase RFC2231:\n\t\tinType = []string{quotedParam(s, \"name\", name)}\n\t\tinDisposition = rfc2231Params(s, name)\n", + "TestEveryEMLSettingSurvivesItsReferenceTool", + ), + ( + # Every attached file is made with its neighbour's seed: the sizes, + # the names and the structure hold, and the files are not the ones the + # message's seed makes (RC4). + "eml attaches files made with the next file's seed", + "internal/format/eml/attach.go", + "c, err := p.plan(desc, g.Bytes, len(p.out))", + "c, err := p.plan(desc, g.Bytes, len(p.out)+1)", + "TestGosStandardLibraryReadsEveryEMLTheWayItWasMeasured", + ), + # --- eml, the second set (2026-10-13, docs/EML-2-2026-10-13.md) --- + ( + "a text in base64 at an odd size with crlf stops taking the long Message-ID", + "internal/format/eml/eml.go", + "m.blanks, m.longID = int(left/eol), left%eol == 1", + "m.blanks, m.longID = int(left/eol), false", + "TestEveryBase64TextSizeIsExact", + ), + ( + "windows-1250 writes the s with an acute the way iso-8859-2 does", + "internal/format/eml/text.go", + "'ś': 0x9c", + "'ś': 0xb6", + "TestTheCharsetsWriteTheLettersTheyDifferIn", + ), + ( + "a message attached to a message stops taking the line endings of the one around it", + "internal/format/eml/attach.go", + "r.Properties = map[string]string{LineEndings: p.s.endings}", + "r.Properties = map[string]string{}", + "TestAMessageCarriesAMessageAsItIs", + ), + ( + "message_depth forwards one message fewer than asked", + "internal/format/eml/attach.go", + "if p.s.depth < 2 {", + "if p.s.depth < 3 {", + "TestDepthForwardsTheSmallestMessagesInsideEachOther", + ), + ( + "the pictures take a seed that is not the one of their place", + "internal/format/eml/inline.go", + "first := len(p.out)", + "first := len(p.out) + 1", + "TestPicturesAreTheFilesTheirSeedsMakeAndTheHTMLShowsEach", + ), + ( + "To loses its last recipient", + "internal/format/eml/header.go", + "for i := 2; i <= m.s.recipients && !o.cancelled(); i++ {", + "for i := 2; i < m.s.recipients && !o.cancelled(); i++ {", + "TestToCarriesEveryRecipientOnALineOfItsOwn", + ), + ( + "any format is taken as a picture in the text", + "internal/format/picture/picture.go", + 'return desc, strings.HasPrefix(desc.MediaType, "image/"), nil', + 'return desc, true, nil', + "TestAPictureInTheTextHasToBeAPicture", + ), + ( + "a message with files refused as too small stops saying they travel in base64", + "internal/format/eml/eml.go", + "if len(m.children)+len(m.pictures) > 0 {", + "if false {", + "TestEMLRefusesASizeBelowItsAttachmentsInBase64", + ), + ( + "a label written by hand is left behind under a key no setting declares", + "internal/gui/text/labels.go", + '"inline": "Pictures",', + '"inlines": "Pictures",', + "TestEveryLabelWrittenByHandBelongsToADeclaredSetting", + ), + # --- HTML carries pictures inside the page (2026-10-14, + # docs/HTML-3-2026-10-14.md) --- + ( + "a picture in the page takes the seed of the page instead of the seed of its place", + "internal/format/htmlfile/pictures.go", + "Seed: core.FileSeed(r.Seed, i), Label: r.Label}", + "Seed: r.Seed, Label: r.Label}", + "TestAPictureInAPageIsTheFileItsFormatWritesAlone", + ), + ( + "the floor of a page leaves its pictures out", + "internal/format/htmlfile/html.go", + "min := minimumFor(s) + pictureBytes", + "min := minimumFor(s)", + "TestEveryHTMLSettingAnswersForItsOwnMinimum", + ), + ( + "the manifest of a page stops recording its pictures", + "internal/format/htmlfile/html.go", + "\tdescribePictures(p.Properties, s.pictures, pictures)\n", + "\t_ = pictures\n", + "TestEveryHTMLSettingIsRecordedInTheManifest", + ), + ( + "a page takes any format as a picture", + "internal/format/htmlfile/pictures.go", + "\tif !ok {\n\t\treturn nil, 0, &format.PropertyValueError{", + "\tif false {\n\t\treturn nil, 0, &format.PropertyValueError{", + "TestAPageRefusesPicturesThatAreNotPictures", + ), + ( + "the pictures of a page add up past the ceiling the sums are counted under", + "internal/format/htmlfile/pictures.go", + "if sum += line.bytes(page.leadBytes()); sum > maxPage {", + "if sum += line.bytes(page.leadBytes()); false {", + "TestAPageOfPicturesTooLargeToCountIsRefused", + ), + ( + "html declares only some of the picture settings", + "internal/format/htmlfile/settings.go", + "return append(own, pictureProperties()...)", + "return append(own, pictureProperties()[:2]...)", + "TestEveryFormatWithPicturesDeclaresThemAlike", + ), + ( + "the structural check stops being told the pictures of a page", + "internal/oracle/strict.py", + " fail(\"the page carries a script, which no setting of this format writes\")\n html_links(bare, settings, nl)\n html_pictures(bare, settings, nl)\n", + " fail(\"the page carries a script, which no setting of this format writes\")\n html_links(bare, settings, nl)\n", + "TestTheStructuralCheckIsToldEveryHTMLSetting", + ), + # --- Stopping: a picture format after the stop, and a container while it + # plans the files inside it (2026-10-14, review of #178, O279) --- + ( + "webp codes its picture to the last row after the run was stopped", + "internal/format/webp/picture.go", + "\t\tif stopped != nil {\n\t\t\treturn nil\n\t\t}\n", + "", + "TestEveryPictureFormatStopsSoonAfterARunIsStopped", + ), + ( + "a zip plans every file inside it whatever the run says", + "internal/format/zip/children.go", + "cp, err := format.PlanWith(ctx, desc.Generator, format.Request{", + "cp, err := desc.Generator.Plan(format.Request{", + "TestAContainerStopsBetweenTheFilesItPlans", + ), + ( + "a mail message hands a stop back as a file that cannot be made", + "internal/format/eml/attach.go", + "return child{}, format.StopOr(p.ctx, core.Refuse(", + "return child{}, (core.Refuse(", + "TestAContainerStopsBetweenTheFilesItPlans", + ), + ( + "the engine plans a container without the run's context", + "internal/engine/crash.go", + "return format.PlanWith(ctx, desc.Generator, r)", + "return format.PlanWith(context.Background(), desc.Generator, r)", + "TestTheEngineHandsTheStopToAContainerItIsPlanning", + ), + ( + "a broken picture inside a page ends the structural check with a traceback", + "internal/oracle/strict.py", + " except Exception as exc:\n fail(f\"picture {n} is not a good {kind} file: {type(exc).__name__}: {str(exc)[:120]}\")\n", + "", + "TestTheStructuralCheckGivesAVerdictOnABrokenPicture", + ), + # --- html: a page on one line and a page with styles (2026-10-14, + # docs/HTML-4-2026-10-14.md) --- + ( + "a page on one line keeps the line breaks of the catalogue of every element", + "internal/format/htmlfile/markup.go", + "m[bodyCatalogue] = oneLineCatalogue(m[bodyCatalogue])", + "m[bodyCatalogue] = m[bodyCatalogue]", + "TestAMinifiedPageIsTheSamePageOnOneLine", + ), + ( + "the catalogue on one line drops its line breaks with nothing in their place, the one in pre included", + "internal/format/htmlfile/markup.go", + "m[bodyCatalogue] = oneLineCatalogue(m[bodyCatalogue])", + "m[bodyCatalogue] = strings.Join(strings.Split(m[bodyCatalogue], string(rune(10))), \"\")", + "TestTheCatalogueOnOneLineReadsAsItDoesOnMany", + ), + ( + "the style attributes are spelled in the other layout than the sheet", + "internal/format/htmlfile/markup.go", + "css[r.element] = r.css(minified)", + "css[r.element] = r.css(!minified)", + "TestInlineStylesAreTheRulesOfTheSheet", + ), + ( + "a fragment ordered with embedded styles carries no sheet", + "internal/format/htmlfile/page.go", + "p := m[sheet]", + "p := \"\"", + "TestAStyleSheetSitsWhereAParserPutsItInTheHead", + ), + # --- html: the review of #179 - styles that say nothing, a stray carriage + # return, and the rules of a sheet on one line (2026-10-14) --- + ( + 'every style rule declares nothing, in both layouts, sheet and attributes alike', + 'internal/format/htmlfile/markup.go', + 'return strings.Join(parts, sep)', + 'return strings.Join(parts[:0], sep)', + "TestInlineStylesAreTheRulesOfTheSheet", + ), + ( + 'the readable style rules lose their colons, so a page of lines declares nothing', + 'internal/format/htmlfile/markup.go', + 'colon, sep := ": ", "; "', + 'colon, sep := " ", " "', + "TestInlineStylesAreTheRulesOfTheSheet", + ), + ( + 'the short style rules lose their colons, so a page on one line declares nothing', + 'internal/format/htmlfile/markup.go', + 'colon, sep = ":", ";"', + 'colon, sep = "", ""', + "TestInlineStylesAreTheRulesOfTheSheet", + ), + ( + 'the structural check lets a carriage return through a page of lines', + "internal/oracle/strict.py", + "if \"\\r\" in text:\n fail(f\"formatting={layout} was ordered and the page has", + "if False:\n fail(f\"formatting={layout} was ordered and the page has", + "TestTheStructuralCheckRefusesStylesThatSayNothingAndStrayCarriageReturns", + ), + ( + 'the structural check takes an empty rule of the sheet for a rule', + 'internal/oracle/strict.py', + 'declared.get(name, False) or bool(HTML_DECLARATION.search(body))', + 'True', + "TestTheStructuralCheckRefusesStylesThatSayNothingAndStrayCarriageReturns", + ), + ( + 'the structural check takes an empty style attribute for a style', + 'internal/oracle/strict.py', + 'rest.startswith(\' style="\') and HTML_DECLARATION.search(', + 'rest.startswith(\' style="\') or HTML_DECLARATION.search(', + "TestTheStructuralCheckRefusesStylesThatSayNothingAndStrayCarriageReturns", + ), + ( + 'the structural check skips every other rule of a sheet on one line', + 'internal/oracle/strict.py', + '(?])([a-z0-9]+)', + "TestTheStructuralCheckIsToldEveryHTMLSetting", + ), + ( + "GitHub leaves the bar of the four screens it shares with Donate", + "internal/gui/window/open.go", + "return []fyne.CanvasObject{donate(h, parts.Quiet).InTheBar(), gitHub(h, parts.Quiet).InTheBar()}", + "return []fyne.CanvasObject{donate(h, parts.Quiet).InTheBar()}", + "TestTheGitHubButtonStandsBesideDonateAndOpensTheProjectPage", + ), + ( + "GitHub opens the support page rather than the project's", + "internal/gui/window/open.go", + "func() { h.OpenLink(text.RepositoryURL) }", + "func() { h.OpenLink(text.SupportURL) }", + "TestTheGitHubButtonStandsBesideDonateAndOpensTheProjectPage", + ), + ( + "the project address on the About screen is folded into the sentence " + "beside it, so nobody can select it in one gesture", + "internal/gui/window/about.go", + "gitHub(h, parts.Secondary), text.RepositoryURL),", + 'gitHub(h, parts.Secondary), text.DetailGitHub()+" "+text.RepositoryURL),', + "TestTheProjectAddressStandsOnItsOwnLine", + ), + ( + "the licence of the fonts the toolkit carries blocks every toolkit update again", + ".github/scripts/dependency_gate.py", + ' "OFL-1.1",\n})', + "})", + "TestTheDependencyGateRefusesALicenceThisProjectCannotDistribute", + ), + ( + "the packages the semgrep job installs are asked for a licence as if they were shipped", + ".github/scripts/dependency_gate.py", + ' and str(d.get("manifest", "")) not in SKIPPED_MANIFESTS]', + " ]", + "TestTheDependencyGateRefusesALicenceThisProjectCannotDistribute", + ), + ( + "an indented page indents three spaces a level", + "internal/format/htmlfile/indent.go", + "const indentUnit = \" \"", + "const indentUnit = \" \"", + "TestAnIndentedPageIsThePageOfLinesIndentedByItsElements", + ), + ( + "a line that closes an element of an indented page stands at the depth inside it", + "internal/format/htmlfile/indent.go", + "\tif strings.HasPrefix(raw, \"Unsubscribe' in body, ", + " (True, ", + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the eml checker takes a frame in a message laid out simple", + "internal/oracle/strict.py", + " if framed:\n", + " if False:\n", + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the eml checker stops reading the lines around a newsletter's plain text", + "internal/oracle/strict.py", + ' if lines[:2] != EML_PLAIN_OPEN or lines[-5:] != EML_PLAIN_CLOSE + [f"Unsubscribe ({unsubscribe})"]:\n', + " if False:\n", + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the eml checker takes the list header lines by their names alone", + "internal/oracle/strict.py", + " if values[name] != value:\n", + " if False:\n", + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + # Review of #186: every word with :// went from the plain text, so an + # address added in the middle passed as the same words. + "the eml checker drops every address from the plain text before comparing words", + "internal/oracle/strict.py", + " return words[:read_more] + words[read_more + 1:-1]\n", + ' return [w for w in words if "://" not in w]\n', + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + # Review of #186: object data, background, srcset, poster and a + # refresh carry an address a mail program loads on opening. + "the eml checker asks only src for an address it would fetch", + "internal/oracle/strict.py", + 'or (remote and (tag, name) != ("a", "href")):\n', + 'or (remote and name == "src"):\n', + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the eml checker takes a picture in the HTML from anywhere but beside it", + "internal/oracle/strict.py", + 'if (name == "src" and not value.startswith("cid:")) or', + 'if (name == "src" and False) or', + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the eml checker takes a style sheet linked by a relative address", + "internal/oracle/strict.py", + ' if tag == "link":\n self.found.append("")\n', + ' if False:\n self.found.append("")\n', + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the eml checker stops reading url( in the styles", + "internal/oracle/strict.py", + 'EML_FETCHING = re.compile(r"url\\(|@import", re.IGNORECASE)', + 'EML_FETCHING = re.compile(r"@import", re.IGNORECASE)', + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the eml checker takes a newsletter whose sheet declares nothing", + "internal/oracle/strict.py", + ' return bool(sheet and re.search(r"\\{[^}:]*:[^}]*\\}", sheet.group(1)))\n', + " return True\n", + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the pull request scan runs the OSV image without the step that holds it to the recorded digest", + ".github/workflows/osv-pull-request.yml", + "- name: the scanner image is the one recorded", + "- name: the scanner image is checked", + "TestTheScannerImageIsCheckedBeforeItRuns", + ), + ( + "the pull request step named for the image check runs something else, so nothing compares the digest", + ".github/workflows/osv-pull-request.yml", + " run: bash .github/scripts/osv_scanner_image.sh", + " run: true", + "TestTheScannerImageIsCheckedBeforeItRuns", + ), + ( + "the weekly scan runs the OSV image without the step that holds it to the recorded digest", + ".github/workflows/osv-weekly.yml", + "- name: the scanner image is the one recorded", + "- name: the scanner image is checked", + "TestTheScannerImageIsCheckedBeforeItRuns", + ), + ( + "the recorded scanner digest loses its algorithm, so the check before every scan fails on every run", + ".github/osv-scanner-digest", + "sha256:", + "", + "TestTheScannerImageIsCheckedBeforeItRuns", + ), + ( + "the scanner image check lets every image through without looking", + ".github/scripts/osv_scanner_image.sh", + "set -euo pipefail", + "set -euo pipefail; exit 0", + "TestTheScannerImageScriptRefusesEveryImageButTheRecordedOne", + ), + ( + "an image pulled under another digest passes the scanner image check", + ".github/scripts/osv_scanner_image.sh", + " status=1", + " status=0", + "TestTheScannerImageScriptRefusesEveryImageButTheRecordedOne", + ), + ( + "the scanner image check joins a digest split in two into one that passes", + ".github/scripts/osv_scanner_image.sh", + "raw=$(cat -- \"$file\" 2>/dev/null || true)", + "raw=$(tr -d '[:space:]' 2>/dev/null < \"$file\" || true)", + "TestTheScannerImageScriptRefusesEveryImageButTheRecordedOne", + ), + ( + "a runner with no scanner image passes the check that relies on one", + ".github/scripts/osv_scanner_image.sh", + "if [ -z \"$ids\" ]; then", + "if false; then", + "TestTheScannerImageScriptRefusesEveryImageButTheRecordedOne", + ), + ( + "the structural check takes a page whose title says nothing", + "internal/oracle/strict.py", + " if end < 0 or not text[start + len(\"\"):end].strip():", + " if False:", + "TestTheStructuralCheckRefusesAPageWhoseTitleSaysNothing", + ), + ( + "the structural check takes a newsletter page that does not say its language", + "internal/oracle/strict.py", + " ('<html lang=\"en\">' in body, \"language of the page\"),", + " (True, \"language of the page\"),", + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "a page with no label goes back to an empty title, which the Nu HTML Checker calls an error", + "internal/format/htmlfile/page.go", + "const placeholderTitle = `<title>Page`", + "const placeholderTitle = ``", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "the page of a newsletter stops saying its language", + "internal/format/eml/newsletter.go", + "\t\thtmlTag: frameLine(s, ``),", + "\t\thtmlTag: frameLine(s, ``),", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "the catalogue's forms article is named forms again, which DOMPurify takes out and two links lose", + "internal/format/htmlfile/catalogue.go", + "
", + "
", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "the catalogue's hidden field is named hidden again, which DOMPurify takes out of a submitted form", + "internal/format/htmlfile/catalogue.go", + "name=\"tfg-hidden\"", + "name=\"hidden\"", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + "the structural check takes a newsletter whose Polish line is not marked as Polish", + "internal/oracle/strict.py", + " page.append(('' in body, \"the Polish line marked as Polish\"))", + " page.append((True, \"the Polish line marked as Polish\"))", + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "the structural check takes a newsletter whose Japanese line is not marked as Japanese", + "internal/oracle/strict.py", + " page.append(('' in body, \"the Japanese line marked as Japanese\"))", + " page.append((True, \"the Japanese line marked as Japanese\"))", + "TestTheEMLCheckRefusesANewsletterBrokenOnPurpose", + ), + ( + "a newsletter's line in Polish and Japanese stops saying its languages under a page that says English", + "internal/format/eml/text.go", + "\tif s.layout != Newsletter {", + "\tif true {", + "TestOurOwnGeneratorsHaveNotDrifted", + ), + ( + # A line end with no space in front of it: RTF ignores the line end, so the + # two words on either side of it become one in every reader. The size holds, + # since the closing words take up the byte. + "rtf breaks a line between two letters", + "internal/format/rtf/blocks.go", + "\tlineBreak = \" \\r\\n\"\n", + "\tlineBreak = \"\\r\\n\"\n", + "TestAnRTFCutAtAnySizeEndsOnItsBraces", + ), + ( + # Two fallback bytes after a number the count says one byte follows: a + # reader that keeps the count shows the second byte as a letter. The size + # holds, and only the structural check reads the count. + "rtf writes the japanese fallback under a count of one", + "internal/format/rtf/text.go", + "\t\tdst, uc = appendCount(dst, len(inPage), uc)\n", + "\t\tdst, uc = appendCount(dst, 1, uc)\n", + "TestEveryRTFSettingSurvivesItsReferenceTool", + ), + ( + # The code page byte of one Polish letter from ISO-8859-2 rather than + # Windows-1250: one byte for one byte, so the size holds, and the letter reads + # as another one in every reader that honours the font. + "rtf writes the polish a in latin 2", + "internal/format/rtf/text.go", + "'Ä…': \"\\xb9\"", + "'Ä…': \"\\xb1\"", + "TestEveryRTFSettingSurvivesItsReferenceTool", + ), + ( + # An emoji as one number instead of a surrogate pair - past the signed 16 + # bit number the specification allows. The closing words take up the bytes. + "rtf writes an emoji as one number past the range", + "internal/format/rtf/text.go", + "\tif r < 0x10000 {\n\t\treturn []rune{r}\n", + "\tif r < 0x110000 {\n\t\treturn []rune{r}\n", + "TestEveryRTFSettingSurvivesItsReferenceTool", + ), + ( + # The properties, the page header and the footer after the label instead of + # in front of the text: the same bytes in another place, so the size holds. + "rtf puts the page header and footer after the label", + "internal/format/rtf/rtf.go", + "\tif s.every {\n\t\tl.head += catalogueHead\n\t}\n", + "\tif s.every {\n\t\tl.body += catalogueHead\n\t}\n", + "TestEveryRTFSettingSurvivesItsReferenceTool", + ), + ( + # The hidden text of every shown in italics: one letter for one, so the + # size holds, and pandoc returns a sentence elements says it leaves out. + "rtf writes the hidden text in italics", + "internal/format/rtf/catalogue.go", + "{\\v This sentence is hidden text.}", + "{\\i This sentence is hidden text.}", + "TestEveryRTFSettingSurvivesItsReferenceTool", + ), + ( + # literal writes the question mark as a byte, the way unicode does: the + # closing words take up the difference, and pandoc then reads the Polish + # line text_encoding says it reads wrong. + "rtf writes literal the way unicode does", + "internal/format/rtf/text.go", + "\t\t\tdst = appendNumber(dst, u)\n\t\t\tdst = append(dst, '?')\n", + "\t\t\tdst = appendNumber(dst, u)\n\t\t\tdst = appendHex(dst, \"?\")\n", + "TestEveryRTFSettingSurvivesItsReferenceTool", + ), + ( + # The label is left out of a document too small for it, and the note says + # something else: the size holds and label_embedded is right. + "rtf leaves out its label under a code nothing reads", + "internal/format/rtf/rtf.go", + "\t\t\t\tCode: \"label_omitted\",\n", + "\t\t\t\tCode: \"label_dropped\",\n", + "TestAnRTFTooSmallForItsLabelSaysItCarriesNone", + ), + ( + # The manifest key of the code page spelled another way: every file the + # same, and a test asserting on the public name finds nothing. + "rtf records the code page under another key", + "internal/format/rtf/settings.go", + "\tCodePage = \"code_page\"\n", + "\tCodePage = \"codepage\"\n", + "TestTheRTFManifestRecordsEverySetting", + ), + ( + # Every picture planned with the seed of the place after its own: each is + # still a real file of the size ordered, so the document is the same size, + # and none is the file its format writes alone for that place (RC4). + "rtf plans a picture with the seed of the next place", + "internal/format/rtf/pictures.go", + "Seed: core.FileSeed(r.Seed, i), Label: r.Label}", + "Seed: core.FileSeed(r.Seed, i+1), Label: r.Label}", + "TestAPictureInAnRTFIsTheFileItsFormatWritesAlone", + ), + ( + # Width and height swapped in the group: 640 and 480 are three digits each, + # so the size holds, and the group says a picture the file is not. + "rtf writes the size of a picture in pixels the other way round", + "internal/format/rtf/pictures.go", + "`\\picw%d\\pich%d\\picwgoal%d\\pichgoal%d`, w, h, gw, gh)", + "`\\picw%d\\pich%d\\picwgoal%d\\pichgoal%d`, h, w, gw, gh)", + "TestAPictureInAnRTFIsTheFileItsFormatWritesAlone", + ), + ( + # The largest picture is no longer narrowed: 9600 and 7200 twips are as many + # digits as 8640 and 6480, so the size holds, and it stands past the column. + "rtf lets a picture past the column", + "internal/format/rtf/pictures.go", + "\tif gw > columnTwips {\n", + "\tif gw > columnTwips+960 {\n", + "TestAnRTFPictureStaysInsideTheColumn", + ), + ( + # A gif written as if it were a png: planned and written to the size ordered, + # and a reader that trusts the word RTF names it by gets a file that is not one. + "rtf takes gif as a picture it can name", + "internal/format/rtf/pictures.go", + "var blips = map[string]string{\"png\": `\\pngblip`, ", + "var blips = map[string]string{\"gif\": `\\pngblip`, \"png\": `\\pngblip`, ", + "TestAnRTFRefusesPicturesItCannotCarry", + ), + ( + # Without the check of each picture on its own, a picture so large that its + # hex does not fit in an int64 wraps round and the document is planned at a + # size that is a lie - measured on 2026-10-15, it came back planned. + "rtf counts a picture twice its own size without asking whether that fits", + "internal/format/rtf/pictures.go", + "\t\tif cp.Bytes > maxDocument {\n\t\t\treturn nil, 0, documentTooLarge(cp.Bytes)\n\t\t}\n", + "", + "TestAnRTFOfPicturesTooLargeToCountIsRefused", + ), + ( + # The two reasons of the refusal swapped: the same minimum, refused and then + # taken, and the reason tells a tester who ordered binary that hex doubles it. + "rtf names hex where the pictures are binary", + "internal/format/rtf/rtf.go", + "\tcase s.pictures.Count > 0 && s.binary:\n", + "\tcase s.pictures.Count > 0 && !s.binary:\n", + "TestAnRTFTooSmallForItsPicturesSaysHowTheyAreWritten", + ), + ( + # The end of the bookmark after the first link rather than the last: the + # same bytes in another place, so the size holds, and the bookmark the first + # kind points at no longer spans the list. + "rtf closes the bookmark of the links after the first one", + "internal/format/rtf/links.go", + "\t\tif i == n-1 {\n\t\t\tout.add(bookmarkEnd)\n", + "\t\tif i == 0 {\n\t\t\tout.add(bookmarkEnd)\n", + "TestEveryRTFSettingSurvivesItsReferenceTool", + ), + ( + # One letter for one in the text of a kind: the size holds, and the link no + # longer says which kind it is in the words a test reads. + "rtf writes the text of the relative link wrong", + "internal/format/rtf/links.go", + "{`HYPERLINK \"page-2.rtf\"`, \"relative link\"},", + "{`HYPERLINK \"page-2.rtf\"`, \"relative lynk\"},", + "TestEveryRTFSettingSurvivesItsReferenceTool", + ), + ( + # The list of containers asked to stop shrinks to the two archives, and the + # guard of stopping goes on green without a format that carries pictures. + "the containers asked to stop are found by a setting nobody declares", + "internal/guard/containerstop_test.go", + "\t\t\tif p.Name == pic.Count {\n", + "\t\t\tif p.Name == pic.Count+\"_count\" {\n", + "TestEveryFormatThatCarriesPicturesIsAskedToStop", + ), + # --- guards that reached the repository while the entries lived outside it, + # found when the coverage guard first ran in CI (2026-10-15) --- + ( + "a manifest is saved by writing at its name, over whatever another run left there", + "internal/manifest/manifest.go", + "func (m *Manifest) Save(path string) error {\n\tr, err := Claim(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn r.Save(m)\n}", + "func (m *Manifest) Save(path string) error {\n\tf, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\treturn m.Encode(f)\n}", + "TestAManifestIsNeverWrittenOverEvenWhenItAppearsMidRun", + ), + ( + "the reserved manifest takes its name by a rename that replaces a manifest put there during the run", + "internal/manifest/reservation.go", + "\treturn own, core.Publish(tmp, final)\n", + "\treturn own, os.Rename(tmp, final)\n", + "TestAManifestPutThereDuringTheRunIsNotWrittenOver", + ), + ( + "a run that gives its name back leaves its reservation behind", + "internal/manifest/reservation.go", + "\tr.used = true\n\treturn core.RemoveOwn(r.tmp, r.own)\n}", + "\tr.used = true\n\treturn nil\n}", + "TestAClaimIsGivenBackAndLeavesNoManifest", + ), + ( + "a file is given its name by a rename that replaces whatever another writer put there", + "internal/core/publish.go", + "\treturn PublishThrough(tmp, final, renameNoReplace, os.Link)\n", + "\treturn os.Rename(tmp, final)\n", + "TestAWriterSpinningOnTheNameNeverLosesItsFile", + ), + ( + "eject claims the recipe's name with an empty file before it writes, as it did before O252", + "internal/cli/presetcmd.go", + "\tif _, err := core.WriteNew(path, source, 0o644); err != nil {", + "\tif f, cerr := core.CreateNew(path, 0o644); cerr == nil {\n\t\t_ = f.Close()\n\t}\n\tif _, err := core.WriteNew(path, source, 0o644); err != nil {", + "TestEjectThatCannotWriteLeavesNoEmptyFile", + ), + ( + "pages stop naming the language a search engine serves to everybody else", + "internal/site/render.go", + "\t\t\tv.Alternates = append(v.Alternates, Alternate{Lang: \"x-default\", URL: s.Origin() + pageURL(root, mate)})\n", + "\t\t\t_ = mate\n", + "TestEveryPageSaysWhichLanguageItIsAndWhereItsTranslationsAre", + ), + ( + "the German page describes a damage under a name the program does not have, and loses the one it has", + "web/content/de/site.json", + "\"zero-head\":", + "\"zero-heads\":", + "TestEveryLanguageDescribesEveryDamage", + ), +] diff --git a/internal/guard/mutation/mutate.py b/internal/guard/mutation/mutate.py new file mode 100644 index 00000000..0bfa34fa --- /dev/null +++ b/internal/guard/mutation/mutate.py @@ -0,0 +1,639 @@ +"""Mutation testing for the guards. + +A green suite is not evidence. The evidence is the sentence "this test would be +red if I broke that", and the only way to get it is to break it on purpose. + +Each entry in entries.py breaks one behaviour and names the guard that has to +notice. This runs them, each in a disposable copy of the repository. + + python internal/guard/mutation/mutate.py every entry + python internal/guard/mutation/mutate.py --changed BASE the entries a change reaches + python internal/guard/mutation/mutate.py --shard 2/16 the third of sixteen parts + python internal/guard/mutation/mutate.py --plan how many entries and parts, nothing run + python internal/guard/mutation/mutate.py --json PATH every verdict written to PATH as well + +CI runs it from .github/workflows/mutation.yml, every entry once a week and +the entries a pull request reaches on that pull request. +""" +import argparse +import collections +import json +import queue +import statistics +import sys +import threading +import time +from pathlib import Path + +from entries import MUTATIONS +from pool import DEFAULT_WORKERS, TreeDamaged, TreesNotMade, drop_trees, make_trees +from runner import ( + record_proof, proofs_recorded, require_go, unbuilt_files, unjudged_reason, + ROOT, Budget, TreeCorrupted, below_floor, free_bytes, measured_ceiling, + mutation_applied, no_room_for, run_test, time_cap_for, +) +from selection import ( + canary as selection_canary, changed_files, entries_at, guard_files, reached, + shard, shard_count, +) + + +# The canary. A mutation that deliberately does not compile, so the detection +# of that case is exercised on every run rather than only on the day somebody +# writes a broken mutation by accident. +# +# Without it the BROKEN path is code nobody proves. That is the same gap this +# whole script exists to close, one level further in. +CANARY = ( + "canary: a mutation that cannot compile must report BROKEN", + "internal/core/label.go", + "func Label(formatID string, bytes int64, seed uint64) string {", + "func Label(formatID string, bytes int64, seed uint64) string {\n\tvar orphan int", + "TestOurOwnGeneratorsHaveNotDrifted", +) + + +# How long building the guard binary in an unmutated tree may take. Generous, +# because this is the cold build an entry must not pay for - 409.7 s on the +# owner's machine with cgo - and what it guards against is a build that hangs. +WARM_CAP_SECONDS = 1800 + + +def warm(trees) -> str: + """Build the guard binary in every tree before anything is timed. + + Why, measured on 2026-10-15: the first build in a fresh tree took 409.7 s + and the second 4.8 s. The time ceiling exists to stop an entry that runs + away, and without this the first entry on every worker carried a cold + build of the whole module inside its own ceiling - and a CI machine starts + every run cold. All trees build at once, and the answer is the reason the + run cannot go on, or an empty string. + + It also answers a question nothing asked before: whether the tree builds + at all with no mutation in it. If it does not, every entry would come back + BROKEN and the run would say so seventeen hundred times. + """ + faults: list[str] = [] + + def one(tree) -> None: + result = run_test("", tree.path, None, tree.slot, WARM_CAP_SECONDS) + if result.capped: + faults.append(f"worker {tree.slot}: building took too long ({result.capped})") + elif not result.compiled: + faults.append(f"worker {tree.slot}: the tree does not build with no mutation in it\n" + f"{result.output[-2000:]}") + + started = time.perf_counter() + threads = [threading.Thread(target=one, args=(tree,), daemon=True) for tree in trees] + for t in threads: + t.start() + for t in threads: + t.join() + print(f" {len(trees)} worker tree(s) built in {time.perf_counter() - started:.1f} s, " + f"before anything is timed") + return "\n".join(faults) + + +def check_canary_in(tree) -> bool: + """True when a non compiling mutation is correctly reported as BROKEN. + + Runs in a worker tree like every other entry, so the real repository is + never mutated - not even by the one entry that is meant to leave the tree + unable to compile. + """ + label, rel, old, new, test = CANARY + path = tree.file(rel) + original = path.read_bytes() + if old.encode() not in original: + print(f"SKIP {label}\n pattern not found in {rel} - the canary is stale") + return False + + with mutation_applied(path, original, original.replace(old.encode(), new.encode(), 1)): + compiled = run_test(test, tree.path).compiled + + if compiled: + print(f"HOLE {label}\n a tree that does not compile was reported as compiling, " + f"so every BROKEN below is unreliable") + return False + print(f"ok {label}\n the broken tree was detected") + return True + + +def report_time(timings: list[tuple[float, str, str, str]], wall: float) -> None: + """Say where the minutes went, per entry and per mutated file. + + The run takes about three times what the measured cycle predicts, and that + gap has been open since 2026-08-10. A single median cannot close it: the + cost of one entry is the cost of rebuilding whatever sits above the file it + touches, so a mutation in internal/core rebuilds most of the tree and one + in internal/gui/window rebuilds almost none. Grouping by file is what makes + that visible, and it is measured here rather than reasoned about. + """ + if not timings: + return + seconds = [t for t, _, _, _ in timings] + inside = sum(seconds) + # Wall and sum are no longer the same quantity, and the old arithmetic + # became a false statement the moment entries started overlapping. It + # printed "inside go test 165.8 min (586% of wall)" and then "everything + # else -137.5 min" on the first parallel run: a negative amount of time, + # which is this report lying about the runner rather than measuring it. + # + # So what is printed is what the two numbers actually mean now. Their + # ratio is the concurrency the run ACHIEVED, which is worth more than the + # width that was asked for - it says how much of the width the machine + # gave back, and it is the number to watch when choosing --workers. + # + # "Everything else" is gone rather than fixed. Python's share was 2.7 s of + # a whole sequential pass, so it was never the interesting half, and in a + # parallel run it cannot be derived by subtraction at all. + print("\nwhere the time went") + print(f" wall clock {wall / 60:6.1f} min") + print(f" summed over entries {inside / 60:6.1f} min") + print(f" concurrency achieved {inside / wall:6.2f}x") + print(f" entries {len(seconds)}") + print(f" per entry: median {statistics.median(seconds):5.2f}s " + f"mean {statistics.fmean(seconds):5.2f}s " + f"min {min(seconds):5.2f}s max {max(seconds):5.2f}s") + + print("\n slowest 15 entries") + for secs, label, rel, test in sorted(timings, reverse=True)[:15]: + print(f" {secs:6.2f}s {rel:<42} {label[:44]}") + + by_file: dict[str, list[float]] = collections.defaultdict(list) + for secs, _, rel, _ in timings: + by_file[rel].append(secs) + print("\n by mutated file, costliest first") + ranked = sorted(by_file.items(), key=lambda kv: -sum(kv[1])) + for rel, secs in ranked[:20]: + print(f" {sum(secs) / 60:5.1f} min {len(secs):3d} entries " + f"median {statistics.median(secs):5.2f}s {rel}") + + +# say_wrecked lived here and is gone with the sequential loop. It told the +# reader which file to put back with git checkout, and there is no longer a +# file to put back: entries mutate a worker copy, so the failure it reported - +# a mutant left in the working tree, which happened on 2026-08-26 and was +# noticed only by git status - cannot reach the repository any more. A damaged +# worker is reported as DAMAGED, counted as proving nothing, and its tree is +# rebuilt. Exit code 3 went with it and was documented nowhere. + + +class Outcome: + """What one entry produced, kept apart from how it is reported. + + A named thing rather than a tuple because six verdicts each carry + different extras, and the summary below has to sort them by entry order - + results arrive in the order workers finish, which is not the order they + were submitted. + """ + + def __init__(self, index: int, verdict: str, label: str, rel: str, + test: str, secs: float, detail: str = "") -> None: + self.index = index + self.verdict = verdict + self.label = label + self.rel = rel + self.test = test + self.secs = secs + self.detail = detail + + +# The verdict vocabulary is unchanged, and deliberately so. These tokens are +# what a person greps for after a long run - the session brief for 2026-09-01 +# records a whole pass reported as "nothing worrying" because the reader +# grepped for NOT CAUGHT while the runner writes HOLE. Renaming any of them +# here would repeat that on the next reader. +def judge(index: int, entry, tree, budget, deadline: float, + unbuilt: frozenset[str] = frozenset()) -> Outcome: + """Run one entry in one worker tree and say what happened. + + Runs on the worker's own thread. Everything it touches belongs to that + worker: its tree, its slot in the budget, its own copy of the file. The + main thread does the printing and the proof recording, so neither needs a + lock. + + Two answers come before anything is mutated, and both are UNJUDGED rather + than a verdict. A file that is not in this tree - docs/ is not in the + repository, so a CI checkout has none of it - cannot be mutated here. A Go + file this platform does not build cannot change what any guard sees, so + its guard stays green and the entry would read as a hole. + """ + label, rel, old, new, test = entry + + if not tree.file(rel).exists(): + return Outcome(index, "UNJUDGED", label, rel, test, 0.0, + f"{rel} is not in this tree") + if rel in unbuilt: + return Outcome(index, "UNJUDGED", label, rel, test, 0.0, + f"{rel} is not built on this platform") + + try: + original = tree.read(rel) + except TreeDamaged as damage: + tree.rebuild() + return Outcome(index, "DAMAGED", label, rel, test, 0.0, + f"{damage} - the worker tree was rebuilt") + + if old.encode() not in original: + return Outcome(index, "SKIP", label, rel, test, 0.0, + f"pattern not found in {rel} - the mutation is stale") + + path = tree.file(rel) + try: + with mutation_applied(path, original, + original.replace(old.encode(), new.encode(), 1)): + result = run_test(test, tree.path, budget, tree.slot, deadline) + except TreeCorrupted as damage: + # In the sequential runner this ended the run, because every later + # entry would read the damaged file and take it for its own original. + # The damage cannot leave a worker copy, so the answer is a fresh copy + # and a verdict that says the entry proved nothing. + tree.rebuild() + return Outcome(index, "DAMAGED", label, rel, test, 0.0, + f"{damage} - the worker tree was rebuilt") + + secs = result.seconds + if result.capped: + return Outcome(index, "CAPPED", label, rel, test, secs, result.capped) + if not result.compiled: + return Outcome(index, "BROKEN", label, rel, test, secs) + if result.passed: + reason = unjudged_reason(test, tree.path) + if reason: + return Outcome(index, "UNJUDGED", label, rel, test, secs, reason) + if result.passed: + return Outcome(index, "HOLE", label, rel, test, secs) + return Outcome(index, "ok", label, rel, test, secs) + + +def say(out: Outcome) -> None: + """One entry's verdict, in the wording the sequential runner used.""" + if out.verdict == "SKIP": + print(f"SKIP {out.label}\n {out.detail}", flush=True) + elif out.verdict == "DAMAGED": + print(f"DAMAGED {out.label}\n {out.detail}\n" + f" this entry proved nothing, run it again", + flush=True) + elif out.verdict == "CAPPED": + print(f"CAPPED [{out.secs:5.2f}s] {out.label}\n" + f" stopped on {out.detail} - the guard was not allowed to finish", flush=True) + elif out.verdict == "BROKEN": + print(f"BROKEN [{out.secs:5.2f}s] {out.label}\n" + f" the mutated tree does not compile - this proves nothing, rewrite the mutation", + flush=True) + elif out.verdict == "UNJUDGED": + print(f"UNJUDGED [{out.secs:5.2f}s] {out.label}\n" + f" {out.detail}, so this platform cannot say", flush=True) + elif out.verdict == "HOLE": + print(f"HOLE [{out.secs:5.2f}s] {out.label}\n" + f" {out.test} stayed GREEN - the guard does not guard this", flush=True) + else: + print(f"ok [{out.secs:5.2f}s] {out.label}\n {out.test} went red", flush=True) + + +def dispatch(entries, trees, budget, deadline: float | None = None, + unbuilt: frozenset[str] = frozenset()) -> tuple[list[Outcome], str]: + """Run every entry across the worker trees, printing verdicts as they land. + + entries are pairs of a position in the whole list and the entry, so a + verdict names the same entry whichever part of the list a run was given. + + One thread per tree, each pulling from a shared queue, rather than a pool + that hands trees out per task: a worker owns its tree for the whole run, + so nothing has to decide who may write to which copy. + + The disk floor is still asked BETWEEN entries rather than only at the + start. The estimate is this machine's number and everything else on the + disk is free to move while a run this long goes - and a wide run fills the + cache faster than the sequential one it replaces, not slower. + """ + if deadline is None: + deadline = time_cap_for(len(trees)) + + work: queue.Queue = queue.Queue() + for index, entry in entries: + work.put((index, entry)) + + done: queue.Queue = queue.Queue() + stop = threading.Event() + stopped_because = [] + + def worker(tree) -> None: + while not stop.is_set(): + try: + index, entry = work.get_nowait() + except queue.Empty: + return + low = below_floor() + if low: + stopped_because.append(low) + stop.set() + return + try: + done.put(judge(index, entry, tree, budget, deadline, unbuilt)) + except Exception as error: + # Anything judge did not turn into a verdict - a rebuild that + # could not delete or copy, a process that could not start. + # Before 2026-10-15 the thread died with it, the entry had no + # verdict at all, and a run could still end "all caught" (a + # review of #193). The entry is DAMAGED, and the worker stops, + # because a tree that failed this way cannot be trusted with + # the next one. The others go on. + label, rel, _, _, test = entry + done.put(Outcome(index, "DAMAGED", label, rel, test, 0.0, + f"worker {tree.slot} failed and stopped - {error!r}")) + return + finally: + # Belt and braces over the release inside run_test, and the + # reason is what a leak would DO rather than how likely it is. + # A slot left registered keeps a finished entry's memory in the + # shared total, so the workers still running are measured + # against a number that includes a process which has exited - + # and the ceiling then fires on them. That reads as CAPPED, + # which is this runner saying "proved nothing", about entries + # that were fine. Releasing twice is free; releasing never is + # a run that quietly proves less. + budget.release(tree.slot) + + threads = [threading.Thread(target=worker, args=(tree,), daemon=True) + for tree in trees] + for t in threads: + t.start() + + outcomes: list[Outcome] = [] + alive = len(threads) + while alive: + try: + out = done.get(timeout=0.2) + except queue.Empty: + alive = sum(1 for t in threads if t.is_alive()) + continue + outcomes.append(out) + say(out) + if out.verdict == "ok": + # Recorded here, on the main thread, and against the REAL tree + # rather than the worker copy. The proof is about the content the + # entry was run against, and proven.json is one file that six + # threads must not write at once. O127. + record_proof(out.label, ROOT / out.rel) + + for t in threads: + t.join() + while not done.empty(): + out = done.get() + outcomes.append(out) + say(out) + if out.verdict == "ok": + record_proof(out.label, ROOT / out.rel) + + # Asserted rather than assumed: every entry handed in has a verdict, or the + # run says why not. When every worker has stopped, entries are left in the + # queue that nobody took, and without this they vanished from the count. + judged = {o.index for o in outcomes} + never = sum(1 for index, _ in entries if index not in judged) + if never and not stopped_because: + stopped_because.append(f"{never} entries were never judged - every worker " + f"that could take them had stopped") + + return outcomes, (stopped_because[0] if stopped_because else "") + + +def part(text: str) -> tuple[int, int]: + """I/K as two numbers, refused unless 0 <= I < K.""" + try: + index, count = (int(n) for n in text.split("/")) + except ValueError: + raise argparse.ArgumentTypeError(f"{text!r} is not I/K, for example 0/16") from None + if count < 1 or not 0 <= index < count: + raise argparse.ArgumentTypeError(f"{text!r} names no part - I runs from 0 to K-1") + return index, count + + +def arguments() -> argparse.Namespace: + ask = argparse.ArgumentParser(description="Break each behaviour on purpose and check its guard notices.") + ask.add_argument("--workers", type=int, default=DEFAULT_WORKERS, + help=f"entries run at once, each in its own copy (default {DEFAULT_WORKERS})") + ask.add_argument("--changed", metavar="BASE", + help="only the entries the change since BASE reaches") + ask.add_argument("--shard", type=part, metavar="I/K", + help="only part I of K, counted from 0") + ask.add_argument("--plan", action="store_true", + help="print entries=N, parts=K and shards=[...] for the workflow and run nothing") + ask.add_argument("--json", type=Path, metavar="PATH", + help="write every verdict to PATH as well") + return ask.parse_args() + + +def chosen_entries(args: argparse.Namespace, say) -> list[int] | None: + """The positions this run takes, or None when the selection cannot be trusted.""" + guards = guard_files(ROOT) + faults = selection_canary(MUTATIONS, guards) + if faults: + say("the selection of entries cannot be trusted, so nothing was run:") + for fault in faults: + say(f" {fault}") + return None + if not args.changed: + return list(range(len(MUTATIONS))) + changed = changed_files(ROOT, args.changed) + positions = reached(MUTATIONS, changed, entries_at(ROOT, args.changed), guards) + say(f"{len(positions)} of {len(MUTATIONS)} entries are reached by the " + f"{len(changed)} file(s) changed since {args.changed}") + return positions + + +def write_report(path: Path | None, report: dict) -> None: + """Every verdict as JSON, for the job that puts the parts of a run together.""" + if path is None: + return + path.write_text(json.dumps(report, indent=1), encoding="utf-8", newline="\n") + + +def main() -> int: + args = arguments() + + # The plan is read by the workflow from standard output, line by line as + # key=value, so everything else it says goes to standard error. + say = (lambda text: print(text, file=sys.stderr)) if args.plan else print + positions = chosen_entries(args, say) + if positions is None: + return 2 + if args.plan: + parts = shard_count(len(positions)) + print(f"entries={len(positions)}") + print(f"parts={parts}") + print(f"shards={json.dumps(list(range(parts)))}") + return 0 + if args.shard: + positions = shard(positions, *args.shard) + chosen = [(p, MUTATIONS[p]) for p in positions] + total = len(chosen) + + report = { + "shard": list(args.shard) if args.shard else [0, 1], + "changed": args.changed or "", + "entries": total, + "platform": sys.platform, + "canary": False, + "stopped": "", + "wall": 0.0, + "workers": 0, + "verdicts": [], + } + + missing = require_go() + if missing: + print(missing) + report["stopped"] = missing + write_report(args.json, report) + return 2 + if not proofs_recorded(): + print("proofs are not written down here - tools/mutate is a local directory\n") + if not chosen: + # Not a success by default and not a failure. A part of an empty + # selection is a real state, and the report has to be able to tell it + # from a part that never answered. + print("no entry to run in this part") + report["canary"] = True + write_report(args.json, report) + return 0 + + workers = min(max(1, args.workers), total) + report["workers"] = workers + started = time.perf_counter() + + # Asked once, before anything is copied. The answer does not change while + # the run goes, and refusing here costs a syscall where finding out the + # hard way costs the whole run and a log ending in BROKEN lines. The + # canary mutates a file too, hence the extra entry. + cramped = no_room_for(total + 1, workers) + if cramped: + print(f"not enough disk for this run\n {cramped}") + report["stopped"] = "not enough disk for this run" + write_report(args.json, report) + return 2 + + # Asked once, of go, with the tags and the environment every entry runs + # under. The answer is this platform's and nobody else's. + unbuilt = frozenset(unbuilt_files(ROOT)) + + ceiling = measured_ceiling(workers) + deadline = time_cap_for(workers) + print(f"{free_bytes() / 1024**3:.1f} GB free, {total} entries, " + f"{workers} workers, {ceiling / 1024**3:.1f} GB shared memory ceiling, " + f"{deadline:.0f} s per entry, {len(unbuilt)} Go file(s) not built here") + + try: + trees = make_trees(workers) + except (TreesNotMade, OSError) as error: + # Said in words and written to the report, so a part that could not + # start reads as stopped rather than as a part that never answered. + print(f"no entry was run - the worker trees could not be made:\n {error}") + report["stopped"] = f"the worker trees could not be made - {error}" + write_report(args.json, report) + return 2 + budget = Budget(ceiling) + try: + cold = warm(trees) + if cold: + print(f"\nno entry was run:\n{cold}") + report["stopped"] = "the worker trees could not be built" + write_report(args.json, report) + return 2 + print() + # The canary first and alone. It proves the BROKEN path works, and + # every BROKEN verdict below is unreliable if it does not - so it is + # worth the one entry it costs to run it before the others start. + canary_ok = check_canary_in(trees[0]) + + outcomes, stopped = dispatch(chosen, trees, budget, deadline, unbuilt) + + left = [(tree.slot, moved) for tree in trees for moved in [tree.moved()] if moved] + finally: + drop_trees(trees) + + wall = time.perf_counter() - started + print() + + report.update(canary=canary_ok, stopped=stopped, wall=round(wall, 1), verdicts=[ + {"index": o.index, "verdict": o.verdict, "label": o.label, "file": o.rel, + "guard": o.test, "seconds": round(o.secs, 2), "detail": o.detail} + for o in sorted(outcomes, key=lambda o: o.index)]) + write_report(args.json, report) + + outcomes.sort(key=lambda o: o.index) + timings = [(o.secs, o.label, o.rel, o.test) for o in outcomes if o.secs > 0] + by = lambda v: [o for o in outcomes if o.verdict == v] + + # A worker tree that ended holding something else is worth saying even + # though it is disposable and already gone. It means a restore failed, and + # the entry that failed it proved nothing - the same fact the DAMAGED + # lines above carry, said once more where a reader looks for it. + if left: + print(f"{len(left)} worker tree(s) ended holding something other than they started with:") + for slot, moved in left: + print(f" worker {slot}: {', '.join(moved)}") + print(" the real repository was never mutated, so nothing needs putting back by hand.\n") + + failures = [o.label for o in by("SKIP") + by("BROKEN") + by("HOLE") + by("DAMAGED")] + if not canary_ok: + failures.append(CANARY[0]) + capped_runs = [f"{o.label} ({o.detail})" for o in by("CAPPED")] + unjudged = [f"{o.label} ({o.detail})" for o in by("UNJUDGED")] + + if budget.trips: + # Loud. A shared ceiling that fires quietly turns "six workers were + # too many for this machine today" into "these entries are flaky". + print(f"the shared memory ceiling fired {budget.trips} time(s) - " + f"try --workers {max(1, workers - 2)} if that keeps happening\n") + + if capped_runs: + # Loud, because a ceiling that quietly swallows entries turns a run + # that proves less into a run that looks the same. + print(f"{len(capped_runs)} of {total} hit the resource ceiling and were not judged") + for label in capped_runs: + print(f" capped: {label}") + print(f" These are proven caught by the full runs of 2026-08-11, before the ceiling existed.\n" + f" Raising it means giving the machine back to the mutation - see docs/QUALITY.md.\n") + if unjudged: + # Said out loud rather than left out. A mutation nobody can judge here + # is not a mutation that passed, and the difference disappears if the + # only record of it is a line somewhere above. + print(f"{len(unjudged)} of {total} could not be judged on this platform") + for label in unjudged: + print(f" unjudged: {label}") + print() + if failures: + print(f"{len(failures)} of {total} mutations were not caught") + # Named here, not left to be found by eye. A run that says only "1 of + # 240" sends the next reader scanning 240 lines for it. + for label in failures: + print(f" not caught: {label}") + report_time(timings, wall) + return 1 + if stopped: + # Neither "all caught" nor a failure of any guard: the entries after + # this one did not run at all. Rolling it into either sentence would + # let a partial run read exactly like a whole one, which is rule 6. + print(f"STOPPED after {len(outcomes)} of {total} entries - {stopped}") + print(" every verdict above stands and the repository is intact, but the") + print(" entries after this one were not tried.") + print(" go clean -cache gives back what earlier runs left behind.") + report_time(timings, wall) + return 1 + unproven = len(capped_runs) + len(unjudged) + # "all caught" has to mean all of them. An entry stopped by the ceiling or + # skipped by its own guard is neither caught nor a failure, and rolling it + # into the happy sentence is the silence rule 6 forbids. + if unproven: + print(f"{total - unproven} of {total} mutations caught, " + f"{unproven} not judged - listed above") + else: + print(f"all {total} mutations caught") + report_time(timings, wall) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/internal/guard/mutation/pool.py b/internal/guard/mutation/pool.py new file mode 100644 index 00000000..12e2b18f --- /dev/null +++ b/internal/guard/mutation/pool.py @@ -0,0 +1,252 @@ +"""Worker trees and a global memory budget, so entries can run side by side. + +Why this exists, and it is a measurement rather than a preference. A full run +on 2026-09-01 took 74.0 min for 691 entries, and CLAUDE.md explained it with +"every mutation invalidates the cache, so guard.test links again". Measured +2026-09-01 with a -run pattern that matches no test - which builds, links and +starts the binary while running nothing, so the guard's own time is removed: + + go test, nothing changed at all 3.41 s (3.25-3.55, 4 rounds) + the prebuilt binary, run directly 0.13 s (0.12-0.17) + go test after touching a source file 3.80 s (3.28-4.93) + +The relink costs 0.39 s. The go command spends 3.28 s deciding it has nothing +to do, and it spends it on every entry including the 69 that mutate no Go file +at all - 691 x 3.28 s is 37.8 min of the 74. So the minutes are not in the +compiler, and making the compiler's job smaller was never going to pay. + +What does pay is running entries at the same time. Measured the same day, the +same command K at a time, widths 1/2/4/6/8 interleaved over 3 rounds: + + per entry 3.33 1.83 1.36 1.15 1.15 s + +That is 2.90x, saturating at width 6 - width 8 buys nothing, on a machine with +16 cores, so the limit is filesystem metadata rather than the processor. Six is +therefore the default, and asking for more is allowed but will not help. + +Entries cannot share a tree: each one puts a mutant on disk and takes it off +again, and two doing that at once would judge each other's damage. So every +worker gets its own copy. Measured: 3.55 s and 82.8 MiB per copy, and six of +them cost 21 s of setup and 0.48 GiB, against a run that saves half an hour. + +The copy carries .git, and that is not tidiness. Six entries name guards that +read tracked files, commit messages and branch names. Those guards call +t.Skipf when git cannot answer, so a tree without .git does not produce a +false verdict - it produces UNJUDGED, and the run would quietly prove six +entries less than the sequential one it replaces. Verified rather than +assumed: with .git copied, TestNoCommitMessageCarriesPrivateContent PASSES in +a worker tree. + +What the copy leaves out is tfg-out (251 MB of generated files) and dist +(77 MB of release artefacts). Neither is an input to any build. + +One thing this buys that has nothing to do with speed: the real tree is never +mutated. On 2026-08-26 a run that filled the disk left a mutant in the working +copy and the only thing that noticed was git status. A worker tree is +disposable, so the same failure now costs a re-copy instead of a hand repair. +""" +import os +import shutil +import stat +import tempfile +import time +from pathlib import Path + +# The root of the repository, found from where this file sits rather than +# written down. This file is internal/guard/mutation/pool.py, three levels +# below it. Until 2026-10-15 it was an absolute path on one Windows machine. +ROOT = Path(__file__).resolve().parents[3] + +# Not inputs to any build. tfg-out alone is 251 MB and would be copied per +# worker for nothing. +NOT_COPIED = {"tfg-out", "dist", "__pycache__", ".pytest_cache"} + +# Lock files are excluded because a copy taken while git is mid-operation +# otherwise carries an index.lock and the copy looks permanently busy. +NOT_COPIED_SUFFIX = (".exe", ".lock") + +# What worker trees were called before 2026-10-15, a fresh name per run. Still +# swept, so a machine that ran the old runner gets its space back. +PREFIX = "tfg-mutate-tree-" + +# Where worker trees live: one fixed directory, the same on every run, and that +# is the speed of a run rather than tidiness. Go keys its build cache on the +# directory a package sits in, so a tree under a new name builds every package +# of this module from nothing. Measured 2026-10-15 on the guard binary: 409.7 s +# in a fresh copy, 4.8 s for the same copy built again, 4.0 s in the checkout +# itself. Under a fresh name per run, the first entry of every worker paid that +# cold build inside its own time ceiling. A fixed path lets the next run find +# what the last one built. +TREES = "tfg-mutate-trees" + +# Measured 2026-09-01: 82.8 MiB per copy. Rounded up, because this feeds the +# disk check and erring high refuses a run that would have fitted while erring +# low lets through one that cannot finish. +TREE_BYTES = 90 * 1024**2 + +# Where the speedup stops. Measured, not chosen: width 8 costs the same per +# entry as width 6 on a 16 core machine. +DEFAULT_WORKERS = 6 + + +def _force_writable(func, path, _exc): + """Retry a delete after clearing the read-only bit. + + Git object files are read-only on Windows, so rmtree over a copied .git + fails half way and leaves most of the tree behind. Without this the trees + accumulate in TEMP exactly like the 28 orphaned go-build directories that + were found there on 2026-08-26. + """ + try: + os.chmod(path, stat.S_IWRITE) + func(path) + except OSError: + pass + + +def _ignore(_dirpath: str, names: list[str]) -> set[str]: + out = {n for n in names if n in NOT_COPIED} + out |= {n for n in names if n.endswith(NOT_COPIED_SUFFIX)} + return out + + +def sweep_old_trees() -> int: + """Delete worker trees an earlier run left behind, and say how many. + + Runs before anything is created. A crashed run cannot clean up after + itself, and 83 MiB a time is how TEMP quietly fills - this project has + already measured 1.4 GB of orphaned go-build directories sitting there for + a week with nothing to remove them. + """ + parent = Path(tempfile.gettempdir()) + gone = 0 + try: + for path in [*parent.glob(PREFIX + "*"), parent / TREES]: + if path.is_dir(): + shutil.rmtree(path, onerror=_force_writable) + gone += 1 + except OSError: + pass + return gone + + +class WorkerTree: + """One disposable copy of the repository, and the bytes it started with. + + Baselines are kept per tree rather than globally: the failure they catch is + an entry inheriting the damage of the one before it, and after this change + "the one before it" means the previous entry ON THIS WORKER. + """ + + def __init__(self, slot: int, path: Path) -> None: + self.slot = slot + self.path = path + self._first: dict[Path, bytes] = {} + + def file(self, rel: str) -> Path: + return self.path / rel + + def read(self, rel: str) -> bytes: + """The file's bytes now, refusing if they moved since this tree was made.""" + path = self.file(rel) + current = path.read_bytes() + known = self._first.setdefault(path, current) + if current != known: + raise TreeDamaged( + f"{rel}: changed between entries on worker {self.slot}, so the " + f"entry before this one did not put it back") + return current + + def moved(self) -> list[str]: + """Every touched file that no longer holds what it started with.""" + out = [] + for path, want in self._first.items(): + try: + if path.read_bytes() != want: + out.append(str(path.relative_to(self.path))) + except OSError: + out.append(str(path)) + return out + + def rebuild(self) -> None: + """Throw this copy away and make it again. + + The whole reason a damaged worker is survivable. In the sequential + runner a bad restore ended the run, because every later entry would + read the damaged file and take it for its own original. Here the + damage cannot leave the copy, so the answer is a fresh copy. + """ + shutil.rmtree(self.path, onerror=_force_writable) + shutil.copytree(ROOT, self.path, ignore=_ignore, symlinks=True) + self._first.clear() + + +class TreesNotMade(Exception): + """The worker trees could not be set up, so no entry can run.""" + + +class TreeDamaged(Exception): + """A worker tree holds something other than what it started with. + + Separate from runner.TreeCorrupted, and the difference is what it costs. + TreeCorrupted is about the REAL tree and ends the run, because somebody has + to put a file back by hand. This one is about a copy nobody will ever + commit, so it is recoverable by rebuilding that copy. + """ + + +def make_trees(count: int, say=print) -> list[WorkerTree]: + """Copy the tree count times, and say what that cost. + + Said out loud because it is dead time before the first verdict, and a + setup step that takes twenty seconds without explaining itself reads as a + hang. + """ + swept = sweep_old_trees() + if swept: + say(f" swept {swept} worker tree(s) left by an earlier run") + + started = time.perf_counter() + # Copied fresh every run, so nothing an earlier run left in a tree can + # reach this one - only the path is the same, which is all the cache needs. + # + # The sweep above cannot fail loudly - it clears read-only bits and goes + # on - so a file it could not delete leaves the directory behind. A file + # held open on Windows is enough. Refused here in words, rather than + # with the traceback of a mkdir or a copy into a tree that is half there + # (a review of #193). + parent = Path(tempfile.gettempdir()) / TREES + if parent.exists(): + left = sorted(p.name for p in parent.iterdir()) + raise TreesNotMade(f"{parent} is still there after the sweep, holding " + f"{', '.join(left) or 'nothing'} - something has a file " + f"in it open. Close it, delete the directory and run again.") + parent.mkdir() + trees = [] + for slot in range(count): + path = parent / f"w{slot}" + shutil.copytree(ROOT, path, ignore=_ignore, symlinks=True) + trees.append(WorkerTree(slot, path)) + say(f" {count} worker trees in {time.perf_counter() - started:.1f} s under {parent}") + return trees + + +def drop_trees(trees: list[WorkerTree]) -> None: + """Remove the trees and the directory holding them. + + Never raises. This runs in a finally, and a failure to tidy up must not + replace whatever the run was actually reporting - a traceback here would + bury the verdicts. + """ + if not trees: + return + parent = trees[0].path.parent + for tree in trees: + try: + shutil.rmtree(tree.path, onerror=_force_writable) + except OSError: + pass + try: + shutil.rmtree(parent, onerror=_force_writable) + except OSError: + pass diff --git a/internal/guard/mutation/report.py b/internal/guard/mutation/report.py new file mode 100644 index 00000000..eb5de81b --- /dev/null +++ b/internal/guard/mutation/report.py @@ -0,0 +1,140 @@ +"""Put the parts of a mutation run together and say what they found. + +Each job of a run writes its verdicts with mutate.py --json. This reads all of +them and writes one summary, and it is the only place that decides whether +the run as a whole went red. + +A part that never answered is a failure, not a smaller run. The job can die on +its time limit, on the machine running out of memory, or on a network fetch +before the first entry, and every one of those leaves no file at all. Counting +only the files that arrived would turn a run that checked half the entries +into one that reads exactly like a whole one. + + python internal/guard/mutation/report.py --dir results --entries 1779 --shards 16 [--gate] + +--gate makes uncaught mutations fail the run. Without it they are reported as +warnings and the run stays green, which is how pull requests are reported +while the runner earns trust on CI. +""" +import argparse +import collections +import json +import os +import statistics +import sys +from pathlib import Path + +# What makes a run red. CAPPED and UNJUDGED are said out loud and are not +# failures: neither is a verdict about the guard. +FAILING = ("HOLE", "BROKEN", "SKIP", "DAMAGED") + + +def command_text(text: str) -> str: + """Text made safe for a workflow command, which ends at the first newline.""" + return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def load(directory: Path) -> list[dict]: + reports = [] + for path in sorted(directory.rglob("result-*.json")): + reports.append(json.loads(path.read_text(encoding="utf-8"))) + return reports + + +def main() -> int: + ask = argparse.ArgumentParser(description="Put the parts of a mutation run together.") + ask.add_argument("--dir", type=Path, required=True, help="where the parts' JSON files are") + ask.add_argument("--entries", type=int, required=True, help="how many entries the plan chose") + ask.add_argument("--shards", type=int, required=True, help="how many parts the plan made") + ask.add_argument("--gate", action="store_true", help="fail the run on any uncaught mutation") + ask.add_argument("--summary", type=Path, default=os.environ.get("GITHUB_STEP_SUMMARY"), + help="where to write the summary (default: the step summary)") + args = ask.parse_args() + + reports = load(args.dir) if args.dir.is_dir() else [] + by_part = {tuple(r["shard"])[0]: r for r in reports} + problems: list[str] = [] + + missing = [i for i in range(args.shards) if i not in by_part] + for i in missing: + problems.append(f"part {i} of {args.shards} did not report - its entries were not run") + strays = [i for i in by_part if not 0 <= i < args.shards] + for i in strays: + problems.append(f"a part numbered {i} reported, and the plan made {args.shards}") + for i, r in sorted(by_part.items()): + if r["shard"][1] != args.shards and args.shards > 0: + problems.append(f"part {i} was cut for {r['shard'][1]} parts, and the plan made {args.shards}") + if r["stopped"]: + problems.append(f"part {i} stopped early - {r['stopped']}") + if not r["canary"]: + problems.append(f"part {i}: the canary failed, so its BROKEN verdicts cannot be trusted") + if len(r["verdicts"]) != r["entries"] and not r["stopped"]: + problems.append(f"part {i} chose {r['entries']} entries and judged {len(r['verdicts'])}") + + run = sum(r["entries"] for r in by_part.values()) + if not missing and run != args.entries: + problems.append(f"the parts chose {run} entries between them, and the plan chose {args.entries}") + + verdicts = [v for r in by_part.values() for v in r["verdicts"]] + counts = collections.Counter(v["verdict"] for v in verdicts) + failing = sorted((v for v in verdicts if v["verdict"] in FAILING), key=lambda v: v["index"]) + unjudged = sorted((v for v in verdicts if v["verdict"] in ("UNJUDGED", "CAPPED")), + key=lambda v: v["index"]) + + level = "error" if args.gate else "warning" + for v in failing: + print(f"::{level} title=mutation {v['verdict']}::" + + command_text(f"{v['label']} - {v['guard']} in {v['file']}")) + for problem in problems: + print(f"::{level} title=mutation run::" + command_text(problem)) + + lines = ["## Mutation run", ""] + lines.append(f"{args.entries} entries planned in {args.shards} part(s), " + f"{len(verdicts)} judged.") + lines.append("") + lines.append("| verdict | entries |") + lines.append("|---|---|") + for name in ("ok", *FAILING, "CAPPED", "UNJUDGED"): + if counts.get(name): + lines.append(f"| {name} | {counts[name]} |") + lines.append("") + if problems: + lines.append("### The run itself") + lines.extend(f"- {p}" for p in problems) + lines.append("") + if failing: + lines.append("### Not caught") + lines.extend(f"- **{v['verdict']}** {v['label']} - `{v['guard']}` in `{v['file']}`" + + (f" ({v['detail']})" if v["detail"] else "") for v in failing) + lines.append("") + if unjudged: + lines.append("
Not judged here " + f"({len(unjudged)})") + lines.append("") + lines.extend(f"- {v['verdict']} {v['label']} - {v['detail']}" for v in unjudged) + lines.append("") + lines.append("
") + lines.append("") + seconds = [v["seconds"] for v in verdicts if v["seconds"] > 0] + walls = [r["wall"] for r in by_part.values() if r["wall"] > 0] + if seconds and walls: + lines.append(f"Longest part {max(walls) / 60:.1f} min. Per entry median " + f"{statistics.median(seconds):.2f} s, longest {max(seconds):.2f} s, " + f"{max(r['workers'] for r in by_part.values())} worker(s) per part.") + if not failing and not problems: + lines.append("") + lines.append("Every judged mutation was caught.") + text = "\n".join(lines) + "\n" + + if args.summary: + with open(args.summary, "a", encoding="utf-8") as out: + out.write(text) + else: + print(text) + + red = bool(failing or problems) + return 1 if red and args.gate else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/internal/guard/mutation/runner.py b/internal/guard/mutation/runner.py new file mode 100644 index 00000000..80da29f6 --- /dev/null +++ b/internal/guard/mutation/runner.py @@ -0,0 +1,616 @@ +"""One way to run a mutated tree, shared by every mutation tool. + +Why it exists. mutate.py, subset.py and try-one.py each held their own copy of +"swap the file, run the guard, decide what happened", and the copies had already +drifted: the two smaller ones still ran a separate "go vet ./..." that the full +runner dropped, and none of them knew about a guard that skips itself. On +2026-08-11 the full runner gained a memory ceiling after the entry that switches +off core.MaxFilesPerRun took the owner's machine down - guard.test reached +19.8 GB of 34.3 GB, twice. The other two would still have done it, and the fact +that the main one was safe by then made that easy to assume away. + +So the deciding lives here and the tools only choose what to feed it. + +The same argument came back on 2026-08-26 for the swap rather than the run. All +three tools held their own copy of "put the mutant down, put the original +back", and none of the three checked that the second half worked. Two runs that +day left a mutated file in the tree and the only thing that noticed either was +git status. That code now lives here as well, and it proves the restore instead +of assuming it. +""" +import contextlib +import functools +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +import threading +import time +from collections.abc import Iterator +from pathlib import Path +from typing import NamedTuple + +# psutil is used to watch the memory of the process tree and nothing else. It +# is installed from .github/requirements-mutation.txt, outside the module and +# outside both binaries, so it is not a dependency of the product and go.mod +# never sees it. +import psutil + +# Worker trees are pool's business, but the disk check below has to account +# for them, so the measured size comes from there rather than being written +# down twice. pool imports nothing from here, so this cannot cycle. +from pool import ROOT, TREE_BYTES + +# The go command on PATH. Until 2026-10-15 this was an absolute path on the one +# Windows machine the runner had ever run on, and CI runs it on Linux. Empty +# when there is none, and require_go says so before anything is copied. +GO = shutil.which("go") or "" + + +def require_go() -> str: + """Why no entry can run here, or an empty string when go was found.""" + if GO: + return "" + return "the go command is not on PATH, so no entry can be built or run" + +# The tags the project ships with, read from the same file the workflows read. +# +# Without them this runner builds a DIFFERENT binary than CI and the release do, +# and at least one guard would then run against an encoder whose assembly reads +# past its buffers - which does not fail a test, it takes the process down, and +# a crash is scored as a mutation that was caught. +BUILD_TAGS = (ROOT / ".github" / "build-tags").read_text(encoding="utf-8").strip() + +# A mutation that removes a refusal makes the code do the thing the refusal +# prevented, and one of those things is "take all the memory on the machine". +# +# Measured 2026-08-11, and not by reading: the entry that switches off +# core.MaxFilesPerRun feeds the reader a count of 9223372036854775807, so it +# grows a list towards nine quintillion entries. The test asserts on "out of +# memory" because that was the original defect, so the mutation cannot be made +# cheap without making it prove something else. +# +# Both ceilings earn their place, measured separately: the count entry is +# stopped by memory at 4.5 GB after 4.92 s, and the markdown entry never nears +# 4 GB and is stopped by time. Neither ceiling alone catches both. An ordinary +# entry takes 1.64 s and touches neither. +MEMORY_CAP_BYTES = 4 * 1024**3 +TIME_CAP_SECONDS = 120 + +# Disk, alongside memory and time, and it earns its place the same way they +# did: by a run that took the machine somewhere it could not come back from. +# +# Measured 2026-08-26 by walking GOCACHE before and after, and it has to be +# done that way. Free space on this machine is not a measurement over minutes: +# it moved by gigabytes in both directions during one session, and a 79 entry +# run showed 7250 MB gone that turned up in neither the cache nor TEMP. Two +# earlier readings taken with df alone said 12.5 and 13.4 MB per entry, and +# they happened to land near the truth rather than being evidence of it. +# +# What the cache actually says, on two fresh subsets: +# internal/engine 47 entries 7.97 -> 8.49 GB 11.3 MB per entry +# internal/manifest 13 entries no change about nothing +# df agreed with the first to within 1%, which is what makes it trustworthy. +# +# So the cost DOES follow how much sits above the mutated package, and an +# earlier version of this comment said the opposite off the df readings alone. +# 14 MB is the conservative end of that range rather than an average - this is +# a safety check, so erring high refuses a run that would have fitted, and +# erring low lets through one that cannot finish. +# +# The build cache evicts nothing while a run is going, so this accumulates for +# the whole pass. Five hundred and ten entries is therefore about 7 GB at +# worst, and the 27 GB that filled the disk was weeks of accumulated cache +# rather than what one run adds. +CACHE_BYTES_PER_ENTRY = 14 * 1024**2 + +# What has to stay free. One entry needs about 63 MB at its peak: 49.8 MB for +# the linked guard.test, measured the same day, plus its cache entry. Two +# gigabytes is thirty of those, and it is deliberately far above one entry - +# the check runs between entries, so a floor a single entry could cross would +# report the damage rather than prevent it. +# +# Overridable, and that is not a convenience. A ceiling nobody can reach on +# purpose is a ceiling nobody has watched work, and this project has thrown +# away six pieces of defensive code for that exact reason. Setting the variable +# to more than the disk holds makes the refusal happen on the next run, which +# is how both paths below were seen to fire at all. +DISK_FLOOR_BYTES = int(os.environ.get("TFG_MUTATE_DISK_FLOOR", 2 * 1024**3)) + + +class Result(NamedTuple): + """What one mutated run produced. + + capped is empty unless the run was stopped for holding too much memory or + running too long. A capped run is not a result: the guard was not allowed + to finish, so it neither caught the mutation nor failed to. Reporting it as + caught would be true by accident, because a killed run does end non-zero. + """ + compiled: bool + passed: bool + seconds: float + capped: str + output: str + + +def time_cap_for(workers: int) -> float: + """The wall clock ceiling for one entry, given how many run at once. + + A flat ceiling was right while entries ran alone and is wrong now. The + ceiling exists to stop a mutation that removed a refusal from doing the + thing the refusal prevented for ever - it is about the ENTRY running away, + not about the machine being busy. But it is measured in wall clock, and + wall clock stretches with contention. + + Measured 2026-09-01 over 48 entries in list order, same block each time: + the entry itself has a median of 4.97-6.76 s at width 1, 7.22 s at width 3, + 12.43 s at width 6 and 21.78 s at width 9. So six workers roughly double an + entry's wall clock while the work inside it is unchanged. + + Left flat, an entry that took 50 s alone would cross a 120 s ceiling at + width 6 and come back CAPPED - which is not a verdict about the mutation at + all, it is a verdict about how many workers the machine happened to be + running. That would make the proof depend on the width, and a run at + width 6 would quietly prove less than the same run at width 1. + + So the ceiling stretches the way the measurement says the entries do: + unchanged at width 1, doubled at width 6. + """ + return TIME_CAP_SECONDS * (1 + (max(1, workers) - 1) / 5) + + +def run_test(name: str, tree: Path = ROOT, budget: "Budget | None" = None, + slot: int = 0, deadline: float = TIME_CAP_SECONDS) -> Result: + """Run the named guard against whatever is currently on disk. + + tree says WHERE. It defaults to the real repository, which is what the + three smaller tools still pass, and the parallel runner hands over a + worker copy instead - two entries sharing a tree would judge each other's + mutants. + + budget is the ceiling across ALL workers, and it is the piece that made + width safe rather than reckless. The per-entry ceilings below are + unchanged and still bind on their own, but six of them side by side would + permit 24 GB on a machine that had 8.0 GB available when this was written. + Measured the same day: an ordinary entry peaks at about 650 MiB, so six of + those are 3.9 GB and fit easily - the danger is the one entry that + legitimately climbs to 4 GB while five ordinary ones are already holding + 3.2 GB. That sum, not the ordinary case, is what this stands in front of. + + The two failure shapes have to be told apart. A test run reports failure + for a mutation that broke the behaviour AND for one that broke the syntax, + and the exit code alone cannot separate them - so a mutation that does not + compile would read as caught while proving nothing. + + There is deliberately no separate "go vet ./..." here. It compiles the + whole tree a second time for every entry, and a mutation invalidates the + build cache, so neither pass is free. Go already says [build failed] in its + own output and reading that costs nothing. + """ + started = time.perf_counter() + proc = psutil.Popen( + [GO, "test", "-tags", BUILD_TAGS, "./internal/guard/", "-run", "^" + name + "$", "-count=1"], + cwd=tree, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + capped = "" + while True: + try: + # Retrying communicate after a timeout does not lose output, which + # is what makes it safe to poll in this loop. + out, err = proc.communicate(timeout=0.25) + break + except subprocess.TimeoutExpired: + used = tree_memory(proc) + # Told to the shared budget before the private ceiling is + # consulted, so the sum is current even for entries that never + # come near 4 GB themselves. A worker that is not the largest + # gets an empty answer and carries on. + shared = budget.note(slot, used) if budget is not None else "" + if used > MEMORY_CAP_BYTES: + capped = f"memory, {used / 1024**3:.1f} GB" + elif shared: + capped = shared + elif time.perf_counter() - started > deadline: + capped = f"time, over {deadline:.0f} s" + if capped: + kill_tree(proc) + out, err = proc.communicate() + break + + elapsed = time.perf_counter() - started + # Every break above lands here, so one release covers them all. A slot + # left registered would count a finished entry's memory against the ones + # still running and cap them for it. + if budget is not None: + budget.release(slot) + output = out + err + if capped: + return Result(True, False, elapsed, capped, output) + if "[build failed]" in output or "setup failed" in output: + return Result(False, False, elapsed, "", output) + return Result(True, proc.returncode == 0, elapsed, "", output) + + +def tree_memory(proc: psutil.Popen) -> int: + """Bytes held by the process and everything under it. + + The tree matters rather than the process: go test is a launcher and the + memory is in guard.test, its child. Watching only what was spawned here + would report a few megabytes while the machine went under. + """ + total = 0 + try: + total = proc.memory_info().rss + for child in proc.children(recursive=True): + try: + total += child.memory_info().rss + except psutil.Error: + pass + except psutil.Error: + pass + return total + + +def kill_tree(proc: psutil.Popen) -> None: + """Kill the children first, then the launcher. + + The other order leaves guard.test holding its memory with nothing waiting + on it, which is the state this ceiling exists to avoid. + """ + try: + for child in proc.children(recursive=True): + try: + child.kill() + except psutil.Error: + pass + proc.kill() + except psutil.Error: + pass + + +def unjudged_reason(name: str, tree: Path = ROOT) -> str: + """Why the named guard said nothing here, or an empty string when it ran. + + Asked only after a guard stayed green, so the normal path pays nothing for + it. A skipped test exits 0 exactly like a passing one, and without this a + runner reports "the guard does not guard this" about a guard that never + ran - which is the same lie as counting a tree that does not compile as + caught. It needs -v, because go test says nothing about a skip without it. + + The second answer came with CI on 2026-10-15. A guard in a file only one + platform builds does not exist on the others, and go test then runs + nothing at all and exits 0 - which read as a hole in a healthy guard. + """ + r = subprocess.run( + [GO, "test", "-tags", BUILD_TAGS, "./internal/guard/", "-run", "^" + name + "$", "-count=1", "-v"], + cwd=tree, capture_output=True, text=True, + ) + output = r.stdout + r.stderr + if "--- SKIP:" in output: + return f"{name} skipped itself here" + if "=== RUN " + name + "\n" not in output: + return f"{name} does not exist on this platform" + return "" + + +def was_skipped(name: str, tree: Path = ROOT) -> bool: + """True when the named guard did not run here. Kept for the smaller tools.""" + return unjudged_reason(name, tree) != "" + + +def unbuilt_files(tree: Path = ROOT) -> set[str]: + """The Go files this platform leaves out of the build, relative to the root. + + A mutation in a file the compiler never reads changes nothing, so its + guard stays green and the entry would read as a hole. Measured on + 2026-10-15: six entries mutate files only Windows builds, and CI on Linux + builds without cgo, which leaves out eight more. The answer comes from go + itself, asked once per run, with the same tags and environment the entries + are run under, so it follows the platform rather than a list kept by hand. + """ + r = subprocess.run( + [GO, "list", "-tags", BUILD_TAGS, "-e", "-json=Dir,IgnoredGoFiles", "./..."], + cwd=tree, capture_output=True, text=True, + ) + if r.returncode != 0: + raise SystemExit(f"go list could not say which files are built here:\n{r.stderr}") + found: set[str] = set() + decoder = json.JSONDecoder() + text, at = r.stdout, 0 + while True: + while at < len(text) and text[at].isspace(): + at += 1 + if at >= len(text): + break + pkg, at = decoder.raw_decode(text, at) + base = Path(pkg["Dir"]).relative_to(tree.resolve()) + for name in pkg.get("IgnoredGoFiles") or []: + found.add((base / name).as_posix()) + return found + + +class TreeCorrupted(Exception): + """The working tree holds something other than what the run started with. + + Raised rather than returned, because there is no useful next entry. The + following one reads the damaged file, takes it for its own original and + restores to it, so one bad restore becomes a mutation that outlives the + run. That is not a worry, it is what happened on 2026-08-26: the entry + after the damaged one counted its pattern, found it zero times and reported + SKIP, which reads as a stale pattern rather than as a wrecked tree. + """ + + +def write_checked(path: Path, want: bytes, what: str) -> None: + """Put bytes on disk, then read them back to prove they are there. + + Bytes rather than text, because writing text on Windows turns every LF into + CRLF and the repository normalises to LF. + + The read back is the point. write_bytes truncates before it writes, so a + failure part way through leaves the file shorter than it was rather than + unchanged - which is worse than not having tried. On 2026-08-26 that + failure was ENOSPC inside the restore, and the run answered with a + traceback and a mutated file left in the tree. + """ + try: + path.write_bytes(want) + except OSError as err: + raise TreeCorrupted(f"{path}: {what} could not be written - {err}") from err + got = path.read_bytes() + if got != want: + raise TreeCorrupted( + f"{path}: {what} was written but the file holds {len(got)} bytes " + f"where {len(want)} were asked for") + + +@contextlib.contextmanager +def mutation_applied(path: Path, original: bytes, mutant: bytes) -> Iterator[None]: + """Hold the mutant on disk for the length of the block, then put the original back. + + The restore sits in a finally, so it runs when the block raised and also + when writing the mutant itself failed half way. Both directions go through + write_checked: a restore that is assumed rather than proven is the hole + this exists to close, and the mutant is written the same way because a + half written mutant would be judged as if somebody had meant it. + """ + try: + write_checked(path, mutant, "the mutation") + yield + finally: + write_checked(path, original, "the original") + + +class Baselines: + """What every mutated file held when the run started. + + Kept per file rather than per entry, because the failure it catches is the + second entry to touch a file and not the first. On 2026-08-26 subset.py + copied a file a previous entry had left mutated, took that copy for the + original and restored to it. Nothing in the run said so. + + This also answers a case nobody has explained. That day a run left + internal/format/registry.go without its sort call at 24 GB free, so the + disk was not the reason, and the same subset run again was clean. A record + of the starting bytes does not need to know why a file moved. + """ + + def __init__(self) -> None: + self._first: dict[Path, bytes] = {} + + def read(self, path: Path) -> bytes: + """The file's bytes now, refusing if they moved since the run started.""" + current = path.read_bytes() + known = self._first.setdefault(path, current) + if current != known: + raise TreeCorrupted( + f"{path}: the file changed between entries, so the entry " + f"before this one did not put it back") + return current + + def moved(self) -> list[Path]: + """Every touched file that no longer holds what it started with. + + Asked once at the end. Without it the last entry of a run is the one + entry whose restore nothing checks afterwards. + """ + return [p for p, want in self._first.items() if p.read_bytes() != want] + + +class Budget: + """One memory ceiling shared by every worker. + + Why a shared one is not the same as six private ones. The private ceiling + is 4 GB because ONE entry needs that much - the one that switches off the + file count limit and grows a list towards nine quintillion. Multiplying it + by the worker count says six workers may hold 24 GB, and this machine has + 31.9 GB total with 8.0 GB available while ordinary work is open. It went + under twice in 2026-08-11 at 19.8 GB in a single process. + + So the number here is taken from what the machine HAS when the run starts, + not from the per-entry ceiling and not from total RAM. It is never allowed + below the per-entry ceiling plus room for the others: a global ceiling + under 4 GB would stop the pathological entry before it reached its own, + turning a documented memory cap into an accident of how busy the machine + happened to be. + + The largest holder is the one told to stop. Capping the smallest would + free almost nothing and would punish an entry for somebody else's + appetite, and capping everybody would throw away five verdicts to protect + against one. + """ + + def __init__(self, ceiling: int) -> None: + self.ceiling = ceiling + self._held: dict[int, int] = {} + self._lock = threading.Lock() + self.trips = 0 + + def note(self, slot: int, used: int) -> str: + """Record this worker's usage, and say whether IT is the one to stop.""" + with self._lock: + self._held[slot] = used + total = sum(self._held.values()) + if total <= self.ceiling: + return "" + worst = max(self._held, key=lambda s: self._held[s]) + if worst != slot: + return "" + self.trips += 1 + return (f"shared memory, {total / 1024**3:.1f} GB across " + f"{len(self._held)} workers over the " + f"{self.ceiling / 1024**3:.1f} GB ceiling") + + def release(self, slot: int) -> None: + with self._lock: + self._held.pop(slot, None) + + +def measured_ceiling(workers: int) -> int: + """What all workers together may hold, from what the machine has now. + + Reserve is deliberate: the owner works on this machine while runs go, and + a ceiling that consumes everything available makes the run correct and the + desktop unusable. The floor keeps the pathological entry able to reach its + own 4 GB ceiling with ordinary workers alongside it. + """ + available = psutil.virtual_memory().available + reserve = 2 * 1024**3 + floor = MEMORY_CAP_BYTES + max(0, workers - 1) * 700 * 1024**2 + return max(floor, available - reserve) + + +@functools.cache +def go_cache() -> Path | None: + """Where go keeps its build cache, asked once. None when go cannot say.""" + if not GO: + return None + r = subprocess.run([GO, "env", "GOCACHE"], capture_output=True, text=True) + path = r.stdout.strip() + if r.returncode != 0 or not path or path == "off": + return None + return Path(path) + + +def on_disk(path: Path) -> Path: + """The nearest part of path that exists, so a volume can be asked about it.""" + while not path.exists() and path.parent != path: + path = path.parent + return path + + +def disk_needs(entries: int, workers: int) -> list[tuple[Path, int]]: + """Each volume a run writes to, as a path on it, and the bytes it needs there. + + The worker trees and the test binaries go under the temporary directory, + and the build cache under GOCACHE. The checkout itself is only read. Until + 2026-10-15 the check asked about the volume of the checkout, which is the + one volume a run does not fill - right on a machine where all three share + a disk, and wrong anywhere they do not (a review of #193). A volume + holding both is asked for the sum. + """ + temp = Path(tempfile.gettempdir()) + cache = go_cache() or temp + needs: dict[int, list] = {} + for path, extra in ((temp, workers * TREE_BYTES), (cache, entries * CACHE_BYTES_PER_ENTRY)): + device = os.stat(on_disk(path)).st_dev + if device in needs: + needs[device][1] += extra + else: + needs[device] = [path, extra] + return [(path, extra) for path, extra in needs.values()] + + +def free_bytes() -> int: + """Bytes free on the fullest of the volumes a run writes to.""" + return min(shutil.disk_usage(on_disk(path)).free for path, _ in disk_needs(0, 0)) + + +def no_room_for(entries: int, workers: int = 1) -> str: + """Why this run will not fit on the disk, or an empty string when it will. + + Asked before the first entry. A run that dies at 459 of 510 has spent forty + minutes to produce a log whose last forty nine lines all read BROKEN, and + the arithmetic that would have refused it up front costs one syscall. + """ + # Worker trees are counted too. Measured 2026-09-01: 82.8 MiB per copy + # with .git, rounded up to 90 in pool.TREE_BYTES. Six of them is half a + # gigabyte that the sequential runner never had to find. + short = [] + for path, extra in disk_needs(entries, workers): + need = extra + DISK_FLOOR_BYTES + free = shutil.disk_usage(on_disk(path)).free + if free < need: + short.append(f"the volume of {path} needs about {need / 1024**3:.1f} GB " + f"and {free / 1024**3:.1f} GB is free") + if not short: + return "" + return (f"{entries} entries need {CACHE_BYTES_PER_ENTRY / 1024**2:.0f} MB each of build " + f"cache as measured, {workers * TREE_BYTES / 1024**2:.0f} MB of worker trees and " + f"the {DISK_FLOOR_BYTES / 1024**3:.0f} GB floor - {'. '.join(short)}.\n" + f" go clean -cache gives back what earlier runs left behind.") + + +# Where proofs are written down, and only on a machine that keeps them. tools/ +# is a local directory outside the repository, so a CI run records nothing and +# says so once rather than failing every write in silence. +PROOFS = ROOT / "tools" / "mutate" / "proven.json" + + +def proofs_recorded() -> bool: + """True when this checkout has somewhere to write proofs down.""" + return PROOFS.parent.is_dir() + + +def content_id(path: Path) -> str: + """Sixteen hex characters of the file's bytes, enough to tell versions apart.""" + return hashlib.sha256(path.read_bytes()).hexdigest()[:16] + + +def record_proof(label: str, path: Path) -> None: + """Writes down that this entry was proven AGAINST THIS CONTENT of its file. + + O127, and it is the one variant of the mutation trap that nothing cheap + catches. An entry can find its pattern, compile, and still prove nothing, + because the text it breaks no longer reaches the assertion: measured on + 2026-08-25, an entry appended a rule to a hint that had been moved behind an + information button the day before, and the guard counts VISIBLE text. The + first variant - no entry at all - is caught by the coverage guard. The + second - a pattern that no longer occurs - is caught by staleness.py in no + time at all. This third one is invisible to both, and only running the + mutation shows it. + + So what is written down is the thing that decides: the content of the file + the entry mutates. When that content changes, the proof is about a version + of the code that is gone, and aging.py says so. It is a REPORT rather than a + gate, because a changed file does not mean the proof is void - it means + nobody has checked since, which is a different sentence and the honest one. + + Failing to write this must never fail a run. The proof already happened. + """ + if not proofs_recorded(): + return + try: + proofs = json.loads(PROOFS.read_text(encoding="utf-8")) if PROOFS.exists() else {} + proofs[label] = content_id(path) + PROOFS.write_text(json.dumps(proofs, indent=1, sort_keys=True, ensure_ascii=False), + encoding="utf-8", newline="\n") + except (OSError, ValueError): + pass + + +def below_floor() -> str: + """Why the run has to stop here, or an empty string when it may go on. + + Checked between entries rather than only at the start, because the estimate + above is this machine's number and anything else on the disk is free to + move while a run is going. + """ + for path, _ in disk_needs(0, 0): + free = shutil.disk_usage(on_disk(path)).free + if free < DISK_FLOOR_BYTES: + return (f"{free / 1024**3:.2f} GB free on the volume of {path}, under the " + f"{DISK_FLOOR_BYTES / 1024**3:.0f} GB floor") + return "" diff --git a/internal/guard/mutation/selection.py b/internal/guard/mutation/selection.py new file mode 100644 index 00000000..dd304bd9 --- /dev/null +++ b/internal/guard/mutation/selection.py @@ -0,0 +1,146 @@ +"""Which entries a run takes: all of them, or the ones a change reaches. + +A pull request runs the entries its change reaches, and the weekly run takes +every one. An entry is reached when the change touches + + - the file the entry mutates, + - the file its guard lives in, or + - the entry itself, which is new or different from the base. + +A change to the runner reaches every entry, because every verdict passes +through it. A bumped dependency reaches none: an entry proves that a guard +notices a break in this repository's code, and a new version of somebody +else's module does not change which breaks are noticed. What a change reaches +in a third place - a helper both sides call - is what the weekly run is for. + +Nothing here imports psutil or calls go, so the plan can be made by a job that +installs neither. +""" +import ast +import math +import re +import subprocess +from pathlib import Path + +ENTRIES = "internal/guard/mutation/entries.py" + +# Every verdict passes through these, so a change to any of them reaches every +# entry. build-tags is here because it decides what is compiled at all. +RUNNER_FILES = frozenset({ + "internal/guard/mutation/mutate.py", + "internal/guard/mutation/runner.py", + "internal/guard/mutation/pool.py", + "internal/guard/mutation/selection.py", + "internal/guard/mutation/report.py", + ".github/requirements-mutation.txt", + ".github/workflows/mutation.yml", + ".github/build-tags", +}) + +# How a run is cut into jobs. A job takes about this many entries, and there +# are never more than this many jobs. Sixteen of the twenty jobs a free plan +# runs at once leaves room for the other workflows, and the weekly run of every +# entry fills sixteen. The first measured run is what these numbers wait for. +ENTRIES_PER_SHARD = 110 +MOST_SHARDS = 16 + +GUARD_DECLARATION = re.compile(r"^func (Test\w+)\(", re.MULTILINE) + + +def read_entries(text: str) -> list[tuple]: + """The MUTATIONS list in the text of entries.py, read without running it.""" + tree = ast.parse(text) + for node in tree.body: + if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "MUTATIONS": + return [tuple(entry) for entry in ast.literal_eval(node.value)] + raise SystemExit(f"no MUTATIONS list in {ENTRIES}") + + +def git(root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True) + + +def changed_files(root: Path, base: str) -> set[str]: + """Every path the change since base touches, refused loudly when git cannot say. + + A failure here must not read as an empty change. That would select nothing, + and a run of nothing is green. + """ + r = git(root, "diff", "--name-only", f"{base}...HEAD") + if r.returncode != 0: + raise SystemExit(f"git could not list what changed since {base}:\n{r.stderr}") + return {line.strip() for line in r.stdout.splitlines() if line.strip()} + + +def entries_at(root: Path, base: str) -> list[tuple] | None: + """The list as it stood at base, or None when the file did not exist there.""" + r = git(root, "show", f"{base}:{ENTRIES}") + if r.returncode != 0: + if "does not exist" in r.stderr or "exists on disk, but not in" in r.stderr: + return None + raise SystemExit(f"git could not read {ENTRIES} at {base}:\n{r.stderr}") + return read_entries(r.stdout) + + +def guard_files(root: Path) -> dict[str, str]: + """Each guard's name and the file it is declared in, relative to the root.""" + found: dict[str, str] = {} + for path in sorted((root / "internal" / "guard").glob("*_test.go")): + rel = path.relative_to(root).as_posix() + for name in GUARD_DECLARATION.findall(path.read_text(encoding="utf-8")): + found[name] = rel + return found + + +def reached(entries: list[tuple], changed: set[str], before: list[tuple] | None, + guards: dict[str, str]) -> list[int]: + """The positions in entries that the change reaches, in list order.""" + if changed & RUNNER_FILES or before is None: + return list(range(len(entries))) + known = set(before) + return [i for i, (_, rel, _, _, test) in enumerate(entries) + if rel in changed or guards.get(test) in changed or entries[i] not in known] + + +def shard(positions: list[int], index: int, count: int) -> list[int]: + """The part of positions that job index of count runs. + + Every count-th entry rather than a block, so the costly areas of the list + are spread over all jobs instead of landing on one. The same inputs give + the same part in every job, so no job needs another's answer. + """ + return positions[index::count] + + +def shard_count(selected: int) -> int: + """How many jobs a run of this many entries is cut into. Zero for none.""" + return min(MOST_SHARDS, math.ceil(selected / ENTRIES_PER_SHARD)) + + +def canary(entries: list[tuple], guards: dict[str, str]) -> list[str]: + """What is wrong with the selection, asked of a case whose answer is known. + + Run before every plan. A selection that quietly picks nothing turns every + pull request into a green run of zero entries, and nothing else would ever + say so - the guard would not be reached, and it would be green honestly. + """ + faults = [] + if not entries: + return ["the list holds no entry, so the selection was not tried"] + first = entries[0] + if 0 not in reached(entries, {first[1]}, entries, guards): + faults.append(f"a change to {first[1]} did not reach the entry that mutates it") + if first[4] not in guards: + faults.append(f"the guard {first[4]} was not found in any file of internal/guard") + elif 0 not in reached(entries, {guards[first[4]]}, entries, guards): + faults.append(f"a change to {guards[first[4]]} did not reach an entry naming its guard") + if 0 not in reached(entries, set(), entries[1:], guards): + faults.append("a new entry was not reached") + if reached(entries, {"no/such/file"}, entries, guards): + faults.append("a change to a file no entry names reached entries anyway") + everything = list(range(len(entries))) + for count in (1, 3, MOST_SHARDS): + parts = [shard(everything, i, count) for i in range(count)] + if sorted(p for part in parts for p in part) != everything: + faults.append(f"cut into {count}, the parts are not the whole list exactly once") + return faults diff --git a/internal/guard/mutation/staleness.py b/internal/guard/mutation/staleness.py new file mode 100644 index 00000000..97d1598b --- /dev/null +++ b/internal/guard/mutation/staleness.py @@ -0,0 +1,59 @@ +"""Check that every mutation still points at code that exists. + +An entry whose pattern is gone reports SKIP during a run and goes past in the +middle of twelve minutes of output, while the coverage guard sees the entry and +is satisfied. That is how 28 of 161 mutations went stale without anything +saying so, mostly when cli.go was split into seven files. + +This is the check that costs nothing: it is pure text. Count the pattern in its +file and demand exactly one. + +Two occurrences is a finding as well, not only zero. All three runners replace +the first occurrence and leave the rest, so a pattern that matches twice +mutates whichever one happens to come first in the file and the entry stops +saying which behaviour it breaks. Until 2026-08-26 they disagreed about even +that - the full runner replaced one and the other two replaced all of them - +which nothing noticed, because this check keeps the count at one. + +Run after any refactor that moves code, rather than waiting for a full pass. +It reads the list and the files and nothing else, so it needs no package +installed. + + python internal/guard/mutation/staleness.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from entries import MUTATIONS # noqa: E402 +from pool import ROOT # noqa: E402 + + +def main(): + missing, doubled = [], [] + for label, rel, old, _new, _test in MUTATIONS: + path = ROOT / rel + if not path.exists(): + missing.append((label, rel, "the file is gone")) + continue + found = path.read_text(encoding="utf-8").count(old) + if found == 0: + missing.append((label, rel, "pattern not found")) + elif found > 1: + doubled.append((label, rel, f"pattern found {found} times")) + + print(f"{len(MUTATIONS)} mutations checked") + for group, name in ((missing, "STALE"), (doubled, "AMBIGUOUS")): + for label, rel, why in group: + print(f"{name:<10} {label}\n {rel}: {why}") + + if missing or doubled: + print(f"\n{len(missing)} stale, {len(doubled)} ambiguous - " + "each one is a guard nobody is proving") + return 1 + print("every pattern occurs exactly once") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index 86d40a77..03a7eb5d 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -13,7 +13,7 @@ import ( // // Mutation is what turns "this test would be red if I broke that" from a // sentence into a fact, and the list of mutations is kept by hand in -// tools/mutate/mutate.py. Kept by hand means somebody eventually does not add +// internal/guard/mutation/entries.py. Kept by hand means somebody eventually does not add // one - and this is not hypothetical. Two rows of the regression surface said a // locality guard was proven by mutation and no entry named one. Found by this // test on 2026-08-01, not by reading. @@ -118,7 +118,7 @@ var notProvenByMutation = map[string]bool{ // - there is no product code underneath it. It reads documents and compares // lists, so a substitution in a .go file never reaches it. // - the substitution that would break it is not a literal. Entries in -// mutate.py are read with ast.literal_eval, so a break that needs ninety +// entries.py are read with ast.literal_eval, so a break that needs ninety // generated lines cannot be written as one. // // A separate list rather than a note in the one above, because "unproven" and @@ -148,7 +148,7 @@ var provenByProbe = map[string]string{ "git update-index --chmod=-x on .github/scripts/make_app_bundle.sh made it red, naming the file, the mode it found and the command that fixes it. " + "Putting the bit back made it green again. " + "A probe rather than a mutation entry because what it reads is not text: the thing that can be wrong is a FILE MODE recorded in the git index, " + - "and entries in mutate.py substitute one string for another inside a file, which never touches a mode. " + + "and entries in entries.py substitute one string for another inside a file, which never touches a mode. " + "Worth having at all because this failure is invisible on the machine it is written on - Windows has no such bit and git bash ignores it, " + "so the script looks fine, passes every guard that reads it as text, and the FIRST release build stops on it with exit 126. " + "That is not hypothetical: make_app_bundle.sh went in as 100644 and this guard is what found it.", @@ -176,18 +176,7 @@ var provenByProbe = map[string]string{ "to after the goroutine that writes the files, builds, and runs the guard. Reported CAUGHT, and the file was restored. " + "A probe rather than a mutation entry for two reasons, and the first one is the interesting one. The obvious substitution - deleting the handle - does NOT compile, because cancel is then unused, " + "and a mutation that does not compile proves nothing while reporting as though it did. The faithful version is a MOVE of fifteen lines rather than a substitution of one piece of text for another, " + - "and mutate.py entries have to be literals.", - - "TestTabbingReachesTheControlsAndSaysInWhatOrder": "broken by hand on 2026-08-13: parts.Row was made to Hide the row it builds, and the guard went red naming four controls - " + - "the size box, the seed box and two explanation buttons - then green again when it was put back. " + - "That break is the real failure mode rather than an invented one. Visible() on a child answers for the CHILD, so a control inside a hidden container still reports itself visible " + - "while the focus chain, which walks the visible tree, steps straight past it. A section quietly hidden is exactly how a screen stops being operable from the keyboard without looking any different in a screenshot. " + - "Not a mutation entry because no substitution of one piece of text for another produces that state today: the failure lives in the disagreement between two ways of asking what is visible, " + - "and nothing in the tree says Hide for a substitution to aim at. " + - "The clause about the strip, added 2026-09-15 when the strip became ours and its words learnt to hold the keyboard, was broken by hand the same way: " + - "hiding every word in parts.Tabbed made it red naming all four words on every screen, and green again when the lines came out. " + - "Taking the words' FocusGained away is not a break this guard can be shown - the product itself refuses to compile, because the strip focuses its own words in two places, " + - "so that half is held by the compiler rather than by a test.", + "and the entries in entries.py have to be literals.", "TestTheTaskbarIconIsTheSameDrawingAsTheWindowIcon": "checked 2026-08-13 with tools/probes/exe-icon.ps1, run on both binaries. " + "It reported 7 of 7 icon images inside tfg-gui.exe and 0 of 7 inside tfg.exe, which is the right answer for a command line with no window. " + @@ -222,7 +211,7 @@ var provenByProbe = map[string]string{ "TestNoFunctionOrFileHasGrownPastWhatAPersonCanFollow": "checked 2026-08-02 three ways, with tools/probes/probe-shape.py: a function grown past 80 lines of code went red, " + "a file grown past 550 went red, and the same function padded with 90 lines of COMMENT stayed green - so the ceiling is on code and not on explaining. " + - "A probe rather than a mutation because the substitution is ninety lines long and mutate.py entries have to be literals.", + "A probe rather than a mutation because the substitution is ninety lines long and the entries in entries.py have to be literals.", "TestEveryIdentifierAReferencePointsAtExists": "checked 2026-08-01 three ways: a reference to a decision that does not exist, " + "a number removed from the middle of a range, and the summary table in CLAUDE.md announcing a range the document no longer defines. All three went red.", @@ -271,6 +260,9 @@ func mutationTargets(body string) map[string]bool { return named } +// mutationEntries is the list of mutations, relative to the root. +const mutationEntries = "internal/guard/mutation/entries.py" + func TestEveryGuardIsEitherProvenByMutationOrListedAsNotProven(t *testing.T) { // The canary, before the list is looked for, so a fresh checkout runs it // too: both quotes read, a quote of one kind closed by the other not. @@ -281,14 +273,13 @@ func TestEveryGuardIsEitherProvenByMutationOrListedAsNotProven(t *testing.T) { root := repoRoot(t) - // The mutation list lives in tools/, which is excluded from the repository, - // so a fresh checkout has nothing to compare against. That skips out loud - // rather than passing quietly. - body, err := os.ReadFile(filepath.Join(root, "tools", "mutate", "mutate.py")) + // The list is tracked since 2026-10-15. Before that it lived in a + // directory outside the repository, this guard returned early on every CI + // run, and eight guards gathered there with nothing breaking them. A list + // that is missing now is a fault, not a reason to look away. + body, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(mutationEntries))) if err != nil { - t.Logf("SKIPPED: tools/mutate/mutate.py is not here, so nothing was compared. "+ - "That directory is excluded from the repository, so this check only runs on a machine that has it. (%v)", err) - return + t.Fatalf("the mutation list could not be read, so no guard was compared with it: %v", err) } named := mutationTargets(string(body)) @@ -317,7 +308,7 @@ func TestEveryGuardIsEitherProvenByMutationOrListedAsNotProven(t *testing.T) { unproven++ default: t.Errorf("%s is a guard nothing breaks on purpose.\n"+ - " Write a mutation for it in tools/mutate/mutate.py, or put it on provenByProbe with what you broke, or on notProvenByMutation to say out loud that it is unproven.\n"+ + " Write a mutation for it in "+mutationEntries+", or put it on provenByProbe with what you broke, or on notProvenByMutation to say out loud that it is unproven.\n"+ " Pick a break that leaves the size and the exit code right, or the mutation only re-proves what other guards already catch.", name) } } @@ -344,7 +335,7 @@ func TestEveryGuardIsEitherProvenByMutationOrListedAsNotProven(t *testing.T) { } for name := range named { if !exists[name] && strings.HasPrefix(name, "Test") { - t.Errorf("tools/mutate/mutate.py aims at %s and no such guard exists - that mutation proves nothing", name) + t.Errorf("%s aims at %s and no such guard exists - that mutation proves nothing", mutationEntries, name) } }