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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,27 @@ because it turns other people's test suites red.

### Changed

- **Notes are reported once per thing they say, not once per file.** A run of
25 000 one-byte text files used to print 25 001 `note:` lines, every one of
them the same sentence about the label not fitting. It now prints one, with
the count and the first three names:

```
note: 25000 files: The label needs 32 B and the file is 1 B, so this file
carries no label. Its name and the manifest still identify it. Named:
files_0001.txt, files_0002.txt, files_0003.txt. 24997 files not named here.
```

A note about a single file still leads with that file's name, unchanged.

The reason this matters beyond tidiness: a run whose manifest will be too big
for this build to read back warns you first, in a line that looked exactly
like the 25 000 that followed it. That warning is the only thing standing
between you and a directory that `verify` and `cleanup` can never read.

**Nothing in the manifest changes.** Every entry still carries its own note,
where a machine reads it and nothing scrolls.

- **Building from source now needs Go 1.27.0.** It used to say 1.26.5, and that
sentence was true of compiling and false of the product. Go 1.27 changed
`compress/flate`, so a copy built on 1.26 answers the same version number,
Expand Down
111 changes: 111 additions & 0 deletions internal/guard/notesgrouping_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package guard

import (
"strings"
"testing"

_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
)

// A note is reported once per thing it says, not once per file.
//
// Measured on 2026-09-06: a run of 25 000 one byte text files emitted 25 001
// "note:" lines on stderr, one per file, every one of them the same sentence
// about the label not fitting. Two consequences, and the second is why this is
// a stability question rather than a cosmetic one.
//
// The advice was per file for a decision that is per target. And the one line
// that matters was buried under them: a run whose manifest will be too big for
// this build to read back says so first, in a "note:" line typographically
// identical to the 25 000 that follow it, and that line is the only thing
// standing between somebody and a directory neither verify nor cleanup can
// ever read. TestARunSaysWhenItsManifestWillBeTooBigToReadBack proves the
// sentence is printed. It cannot prove anybody can see it.
//
// The same reasoning was already applied to the progress bar - throttled and
// silent when stderr is not a terminal, because "thousands of redrawn lines in
// a CI log are worse than no bar". Notes had not had it applied to them.
func TestNotesAreGroupedByWhatTheySayRatherThanOneLinePerFile(t *testing.T) {
const files = 400

said := runCLI(t, "generate", "--format", "txt", "--size", "1b",
"--count", itoa(files), "--dry-run", "--out", t.TempDir())

notes := 0
for _, line := range strings.Split(said, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "note:") {
notes++
}
}

// Asserted rather than assumed. Nought notes would pass every check below
// by saying nothing at all, which is the shape this project keeps meeting:
// a guard that stopped reaching the state it guards.
if notes == 0 {
t.Fatal("the run printed no notes at all, so this guard checked nothing. A one byte " +
"text file cannot carry a label, and saying so is the note this is about.")
}
if notes >= files {
t.Errorf("%d files produced %d note lines, which is one per file:\n%s\n"+
"What to do: group them by what the note says. A reader cannot find the line "+
"that matters in a list this long.", files, notes, said)
}

// The count has to be there, or grouping has thrown away how many files
// this is about and the reader is worse off than with one line each.
if !strings.Contains(said, itoa(files)+" files:") {
t.Errorf("the grouped note does not say how many files it covers:\n%s", said)
}
// And some names, because "400 files carry no label" with no name at all
// gives nobody a place to start looking.
if !strings.Contains(said, "files_0001.txt") {
t.Errorf("the grouped note names no file at all:\n%s", said)
}
// And what it is not showing.
if !strings.Contains(said, "not named here") {
t.Errorf("the grouped note does not say that most of the files are unnamed, so the "+
"three it lists read as the only ones:\n%s", said)
}
}

// One file keeps its name in front, the way it always had it.
//
// The sharp half of the pair. Grouping that dropped the file name would pass
// every check above - the counts and the examples would all be there - while
// making the common case, a single file with something to say about it, worse
// than it was before.
func TestANoteAboutOneFileStillNamesThatFileFirst(t *testing.T) {
said := runCLI(t, "generate", "--format", "txt", "--size", "1b",
"--count", "1", "--dry-run", "--out", t.TempDir())

if !strings.Contains(said, "note:") {
t.Fatal("the run printed no note, so this guard checked nothing")
}
if !strings.Contains(said, "note: files_0001.txt: ") {
t.Errorf("a note about one file no longer leads with that file's name:\n%s", said)
}
if strings.Contains(said, "1 files") {
t.Errorf("the note says \"1 files\":\n%s", said)
}
if strings.Contains(said, "not named here") {
t.Errorf("a note about one file claims to be hiding others:\n%s", said)
}
}

// The number carries the right noun at the boundary where it changes.
//
// Four files with three named leaves exactly one unnamed, which is the only
// count at which the plural is wrong in a way a reader notices. core.Count
// exists for this and the surrounding sentence is a participle, so nothing in
// it agrees with the number.
func TestTheUnnamedRemainderCarriesTheRightNoun(t *testing.T) {
said := runCLI(t, "generate", "--format", "txt", "--size", "1b",
"--count", "4", "--dry-run", "--out", t.TempDir())

if !strings.Contains(said, "1 file not named here") {
t.Errorf("four files with three named should leave \"1 file not named here\":\n%s", said)
}
if strings.Contains(said, "1 files not named") {
t.Errorf("the remainder says \"1 files\":\n%s", said)
}
}
96 changes: 93 additions & 3 deletions internal/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,19 +300,109 @@ func (m *Manifest) Add(f File) {
}
}

// noteExamples is how many file names a grouped note shows before it stops
// listing and starts counting.
//
// Three rather than one, because one name reads as if one file were special
// when the note is about a whole target, and three shows the shape of the
// naming without becoming a list.
const noteExamples = 3

// Notes gathers every note in the run, so a caller can report them without
// walking the entries itself.
//
// GROUPED BY WHAT THE NOTE SAYS, not one line per file, and that is the whole
// point of it. Measured on 2026-09-06: a run of 25 000 one byte text files
// emitted 25 001 "note:" lines on stderr, one per file, every one of them the
// same sentence about the label not fitting. The advice was per file for a
// decision that is per target.
//
// The line that matters was buried under them. A run whose manifest will be
// too big for this build to read back says so first, in a "note:" line
// typographically identical to the 25 000 that follow it - and that one is the
// only thing standing between somebody and a directory neither verify nor
// cleanup can ever read. See echoManifestReach in cli/generate.go.
//
// The same reasoning was already applied to the progress bar, which is
// throttled to 10 Hz and silent when stderr is not a terminal, on the grounds
// that "thousands of redrawn lines in a CI log are worse than no bar"
// (cli/progress.go). Notes had not had it applied to them.
//
// The stored notes do not change. This is a view for a reader - every entry
// keeps its own note in the manifest, where a machine reads it and nothing
// scrolls.
//
// Names are not sorted here and the count is kept rather than the names, so a
// million entry run no longer sorts a million strings to print sixteen lines.
func (m *Manifest) Notes() []string {
var out []string
groups := noteGroups{byDetail: map[string]*noteGroup{}}
for _, f := range m.Files {
for _, n := range f.Notes {
out = append(out, fmt.Sprintf("%s: %s", f.Name, n.Detail))
groups.add(n.Detail, f.Name)
}
}
sort.Strings(out)
sort.Strings(groups.order)

out := make([]string, 0, len(groups.order))
for _, detail := range groups.order {
out = append(out, groups.byDetail[detail].line(detail))
}
return out
}

// noteGroup is one sentence and the files that carry it.
//
// The names are kept only up to noteExamples and the rest is a count, so a
// million entry run holds a handful of strings rather than a million.
type noteGroup struct {
count int
first []string
}

// noteGroups collects them, keeping the order the details were first seen in.
type noteGroups struct {
byDetail map[string]*noteGroup
order []string
}

// add records one note against the file that carries it.
//
// The first few names are taken in the order the manifest lists them rather
// than sorted, which is what makes this cheap AND deterministic: the manifest's
// own order is fixed, guarded by auditorder_test.go, so the same recipe names
// the same few files every time.
func (n *noteGroups) add(detail, name string) {
g := n.byDetail[detail]
if g == nil {
g = &noteGroup{}
n.byDetail[detail] = g
n.order = append(n.order, detail)
}
g.count++
if len(g.first) < noteExamples {
g.first = append(g.first, name)
}
}

// line renders one group for a person to read.
//
// A group of one keeps the shape it always had - the file name in front - so
// the common case of one file with something to say about it does not get worse
// to make the large case better.
func (g *noteGroup) line(detail string) string {
if g.count == 1 {
return fmt.Sprintf("%s: %s", g.first[0], detail)
}
named := strings.Join(g.first, ", ")
if hidden := g.count - len(g.first); hidden > 0 {
return fmt.Sprintf("%s: %s Named: %s. %s not named here.",
core.Count(g.count, "file", "files"), detail, named,
core.Count(hidden, "file", "files"))
}
return fmt.Sprintf("%s: %s Named: %s.",
core.Count(g.count, "file", "files"), detail, named)
}

// Encode renders the manifest as JSON.
func (m *Manifest) Encode(w io.Writer) error {
enc := json.NewEncoder(w)
Expand Down
Loading