From a6b21547d0e8fd4800b6ff8f7c9e3764a32006ab Mon Sep 17 00:00:00 2001 From: E Ismail Date: Sun, 4 Oct 2026 23:11:20 +0300 Subject: [PATCH] pack: the case warning says only what happens to names that differ in case The warning for two names that differ only in capitalisation said they would collide once the site was served, and that the site would serve whichever of them the platform kept. Neither is true. Every step that stores and serves a site keeps a path's letter case, so each spelling is its own file at its own address, and an address with the wrong case is a page that is not there. The detector is unchanged: it still finds every such group over the whole walked tree. Because that tree includes files that never reach the site, the text now says what happens if the names are published rather than that they will be, and what to do about it: keep one if they are meant to be one file, otherwise rename one. A row pins both strings for a two-name and a three-name group and refuses any return of the two false claims. --- internal/pack/findings.go | 26 +++++++++---- internal/pack/findings_test.go | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/internal/pack/findings.go b/internal/pack/findings.go index e4f7796..13bfb8a 100644 --- a/internal/pack/findings.go +++ b/internal/pack/findings.go @@ -140,14 +140,23 @@ func symlinkFindings(symlinks []string, targets map[string]string, names NameSco return out } -// collisionFindings reports names that would become one file once the -// site is served, one finding per colliding GROUP. +// collisionFindings reports names that differ only in letter case, one +// finding per GROUP. // // The group is the unit because that is what a reader can act on: told // about one half of a pair, they would still have to go and find the // other. The comparison is over the WHOLE path — two files with the same -// name in two directories do not collide, and two files whose -// directories differ only in case do. +// name in two directories are not a pair, and two files whose +// directories differ only in case are. +// +// IT IS A WARNING, AND ITS TEXT IS CONDITIONAL. Every step that stores +// and serves a site keeps a path's letter case, so each spelling stays +// its own file at its own address. What is left is that two addresses +// differing only in case are easy to mistype, and a +// case-changed address is a page that is not there. The detector also +// runs over every walked path, including a pair outside the published +// folders that never reaches the site at all, so the text says what +// happens IF the names are published and does not say that they will be. func collisionFindings(paths []string) []check.Finding { groups := make(map[string][]string, len(paths)) var order []string @@ -170,12 +179,13 @@ func collisionFindings(paths []string) []check.Finding { out = append(out, check.Finding{ CheckID: check.IDCaseCollision, Severity: check.SeverityWarning, - Message: fmt.Sprintf( - "%s differ only in capitalisation and would collide once the site is served:", + Message: fmt.Sprintf("%s differ only in capitalisation:", countOf(len(colliding), "file name", "file names")), Paths: check.NewPaths(colliding...), - Next: "Rename one of them so the names differ by more than case, or the site " + - "will serve whichever the platform kept.", + Next: "If they end up on the site, each is its own address, and a link has to " + + "match its capitalisation exactly. " + + "If they are meant to be one file, keep one; otherwise rename one so the " + + "names differ by more than case.", }) } return out diff --git a/internal/pack/findings_test.go b/internal/pack/findings_test.go index 3819524..1f8c249 100644 --- a/internal/pack/findings_test.go +++ b/internal/pack/findings_test.go @@ -174,6 +174,75 @@ func TestCaseCollisionComparesTheWholePath(t *testing.T) { } } +// TestCaseCollisionTextIsConditionalOnReachingTheSite pins the words of +// the warning, because the claim in them is the whole point of it. +// +// The detector runs over EVERY walked path, including files that never +// reach the site — two components under the source folder, say — so the +// text cannot say what WILL happen to the names. It says what happens +// IF they are published: each spelling is kept as its own file at its +// own address, and an address has to match its file's capitalisation +// exactly. The row also refuses the two phrases that made the earlier +// text false: a claim that the names would collide, and a claim that the +// site would serve whichever of them survived. Both the two-name and +// the three-name group are read, because the count is spelled differently +// in each and the Message is built from it. +// +// The expected strings are literals, and the actual ones come from the +// function's own output, so the row can neither copy the text it checks +// nor pass on a text it never read. +// +// REQUIRED MUTATION, run 2026-10-04: six edits to the function, each +// run against this row alone and each restored from a copy compared +// byte for byte. The earlier Message restored; the earlier Next +// restored; the count's two words swapped; "capitalisation" spelled with +// a z; "or they collide" appended to the Next; the Message's trailing +// colon removed. Every one reds both subtests, and none reds only one. +func TestCaseCollisionTextIsConditionalOnReachingTheSite(t *testing.T) { + const wantNext = "If they end up on the site, each is its own address, and a link has to " + + "match its capitalisation exactly. If they are meant to be one file, keep one; " + + "otherwise rename one so the names differ by more than case." + + for _, tc := range []struct { + name string + paths []string + wantMessage string + }{ + { + name: "two names", + paths: []string{"README.md", "readme.md"}, + wantMessage: "2 file names differ only in capitalisation:", + }, + { + name: "three names", + paths: []string{"README.md", "Readme.MD", "readme.md"}, + wantMessage: "3 file names differ only in capitalisation:", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := collisionFindings(tc.paths) + if len(got) != 1 { + t.Fatalf("findings = %d, want one group: %v", len(got), got) + } + f := got[0] + if f.Message != tc.wantMessage { + t.Errorf("Message = %q, want %q", f.Message, tc.wantMessage) + } + if f.Next != wantNext { + t.Errorf("Next = %q, want %q", f.Next, wantNext) + } + for field, text := range map[string]string{"Message": f.Message, "Next": f.Next} { + folded := strings.ToLower(text) + for _, banned := range []string{"collid", "whichever the platform kept"} { + if strings.Contains(folded, banned) { + t.Errorf("%s = %q, which contains %q — a claim the detector cannot make", field, text, banned) + } + } + } + }) + } +} + // --------------------------------------------------------------------- // The key charset — the hard stop // ---------------------------------------------------------------------