fix: stop name censor flagging innocent words like "cons"

The collapse matcher (which catches elongation bypasses like "niiigger")
pre-collapses banned words by reducing every run of a repeated letter to
one. That turns short slurs with a natural double letter into common
substrings — "coons" -> "cons", "boong" -> "bong", "bussy" -> "busy",
"nigger" -> "niger" — and since it substring-matches, those censor
innocent names like console, icons, busy, Bongo, Nigeria, and negative.
Against the live banned list this falsely censored ~half of ordinary
test names.

A collapse hit is only a genuine elongation bypass when the matched text
actually repeats a letter. Filter collapse matches to keep only those
whose span (widened one char each side, to catch a trailing collapsed
duplicate such as "nigg" -> "nig") contains a repeated letter. This can
only remove collapse matches, never add them: a canonically-spelled slur
with no repeat equals its deduped pattern and is still caught by the
literal substring matcher, so no real slur is lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-06-27 16:57:56 +00:00
parent 23e05f0115
commit e4f4b4d310
2 changed files with 53 additions and 2 deletions
+21 -2
View File
@@ -90,14 +90,33 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
skipNonAlphabeticTransformer(),
],
});
// A collapse-matcher hit is only a genuine elongation bypass when the matched
// text actually repeats a letter (e.g. "niiigger", "cooons", "nigg"). The
// deduped patterns collapse a banned word's natural double into a shorter,
// common substring ("coons" -> "cons", "boong" -> "bong", "nigger" -> "niger"),
// so without this guard the collapse matcher censors innocent names like
// "console", "Nigeria", or "busy". A canonical slur with no repeated letter is
// identical to its deduped pattern and is still caught by substringMatcher, so
// requiring a repeat only drops false positives, never real slurs. The span is
// widened by one char each side because a collapsed duplicate at the leading or
// trailing edge (e.g. "nigg" matching "nig") falls just outside the match.
const elongatedCollapseMatches = (input: string, sorted?: boolean) =>
collapseMatcher.getAllMatches(input, sorted).filter((m) =>
/(.)\1/.test(
input
.slice(Math.max(0, m.startIndex - 1), m.endIndex + 2)
.toLowerCase()
.replace(/[^a-z]/g, ""),
),
);
return {
hasMatch: (input: string) =>
input.toLowerCase().includes("kkk") ||
substringMatcher.hasMatch(input) ||
collapseMatcher.hasMatch(input),
elongatedCollapseMatches(input).length > 0,
getAllMatches: (input: string, sorted?: boolean) => [
...substringMatcher.getAllMatches(input, sorted),
...collapseMatcher.getAllMatches(input, sorted),
...elongatedCollapseMatches(input, sorted),
],
} as unknown as RegExpMatcher;
}
+32
View File
@@ -21,6 +21,12 @@ const bannedWords = [
"faggot",
"retard",
"chair", // Test word to verify custom banned words work
// Banned words with a natural double letter: the collapse matcher deduped
// these to common substrings ("coons" -> "cons", "boong" -> "bong") and used
// to censor innocent names. See the elongation regression tests below.
"coons",
"boong",
"bussy",
];
const matcher = createMatcher(bannedWords);
@@ -165,6 +171,32 @@ describe("UsernameCensor", () => {
expect(matcher.hasMatch("snigger")).toBe(false);
});
test("does not censor innocent words that collide with a deduped slur", () => {
// The collapse matcher deduped "coons" -> "cons", "boong" -> "bong",
// "bussy" -> "busy", "nigger" -> "niger". Without the repeated-letter
// guard, every word containing those substrings was censored.
expect(matcher.hasMatch("cons")).toBe(false);
expect(matcher.hasMatch("console")).toBe(false);
expect(matcher.hasMatch("icons")).toBe(false);
expect(matcher.hasMatch("constitution")).toBe(false);
expect(matcher.hasMatch("busy")).toBe(false);
expect(matcher.hasMatch("bong")).toBe(false);
expect(matcher.hasMatch("Bongo")).toBe(false);
expect(matcher.hasMatch("Nigeria")).toBe(false);
expect(matcher.hasMatch("Nigerian")).toBe(false);
});
test("still catches elongated slurs (collapse matcher)", () => {
// The repeated-letter guard must not weaken elongation detection: these
// all have a real repeated letter in the matched span.
expect(matcher.hasMatch("niiigger")).toBe(true);
expect(matcher.hasMatch("niiigger123")).toBe(true);
expect(matcher.hasMatch("cooons")).toBe(true);
expect(matcher.hasMatch("coons")).toBe(true);
expect(matcher.hasMatch("boooong")).toBe(true);
expect(matcher.hasMatch("bussy")).toBe(true);
});
test("catches kkk as substring", () => {
expect(matcher.hasMatch("kkk")).toBe(true);
expect(matcher.hasMatch("KKK")).toBe(true);