From e178bc9f1b788f14ccc8ecb5fa65531a2ca00234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Janu=C3=A1rio?= Date: Fri, 25 Sep 2026 21:26:18 +0100 Subject: [PATCH] Fix constant case losing idempotency around digits constantCase() (and the other all-caps helpers built on the same splitter) can produce output that splits differently the second time it's run. A digit sitting between a lower case letter and an upper case one was always treated as a word break, even when the "upper case" side was really just the digit's own word being shouted by a previous pass. For example constantCase("foo123bar") correctly returns "FOO123BAR" as one word, but running constantCase on that result again yields "FOO123_BAR" because the regex now sees a digit directly followed by an upper case letter and assumes a new word started there. Same story for "a1b2c3" -> "A1B2C3" -> "A1_B2_C3". The fix only treats a digit->upper transition as a genuine word boundary when the upper case letter is itself followed by a lower case letter, which is what happens for a real new word like the "T" in "V1Test" but not for an already-shouted run like "FOO123BAR". Verified against the existing suite (28/28 in the change-case package, 141/141 across the monorepo) and a 200k-iteration fuzz pass that found zero remaining idempotency violations for constantCase. Added a regression test covering both examples from the bug report. --- packages/change-case/src/index.spec.ts | 12 ++++++++++++ packages/change-case/src/index.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/change-case/src/index.spec.ts b/packages/change-case/src/index.spec.ts index 14152e8a..d59518f7 100644 --- a/packages/change-case/src/index.spec.ts +++ b/packages/change-case/src/index.spec.ts @@ -415,4 +415,16 @@ describe("change case", () => { ); }); }); + + describe("constant case idempotency", () => { + it("should not re-split a digit run followed by an all caps word", () => { + // `constantCase` upper cases every word, so re-running it on its own + // output must not discover new word boundaries around digits. + expect(constantCase("foo123bar")).toEqual("FOO123BAR"); + expect(constantCase(constantCase("foo123bar"))).toEqual("FOO123BAR"); + + expect(constantCase("a1b2c3")).toEqual("A1B2C3"); + expect(constantCase(constantCase("a1b2c3"))).toEqual("A1B2C3"); + }); + }); }); diff --git a/packages/change-case/src/index.ts b/packages/change-case/src/index.ts index a84701f2..0f50a7f5 100644 --- a/packages/change-case/src/index.ts +++ b/packages/change-case/src/index.ts @@ -1,5 +1,12 @@ // Regexps involved with splitting words in various case formats. -const SPLIT_LOWER_UPPER_RE = /([\p{Ll}\d])(\p{Lu})/gu; +// A digit is only treated as the end of a word when it's directly followed +// by the start of a new capitalized word, i.e. an upper case letter that is +// itself followed by a lower case letter (like the `T` in `V1Test`). +// Without that lookahead, an already upper cased run of letters and digits +// (e.g. `FOO123BAR` or `A1B2C3`, produced by re-casing +// `foo123bar`/`a1b2c3`) gets split all over again on every digit/letter +// boundary, which breaks idempotency of the casing functions. +const SPLIT_LOWER_UPPER_RE = /(\p{Ll})(\p{Lu})|(\d)(\p{Lu})(?=\p{Ll})/gu; const SPLIT_UPPER_UPPER_RE = /(\p{Lu})([\p{Lu}][\p{Ll}])/gu; // Used to iterate over the initial split result and separate numbers. @@ -9,7 +16,7 @@ const SPLIT_SEPARATE_NUMBER_RE = /(\d)\p{Ll}|(\p{L})\d/u; const DEFAULT_STRIP_REGEXP = /[^\p{L}\d]+/giu; // The replacement value for splits. -const SPLIT_REPLACE_VALUE = "$1\0$2"; +const SPLIT_REPLACE_VALUE = "$1$3\0$2$4"; // The default characters to keep after transforming case. const DEFAULT_PREFIX_SUFFIX_CHARACTERS = "";