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
26 changes: 18 additions & 8 deletions internal/pack/findings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
69 changes: 69 additions & 0 deletions internal/pack/findings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------
Expand Down
Loading