When your list-marker regex eats decimal numbers
TLDR: A regex that strips ordered-list markers ("1.", "a)", "iv.") silently corrupted technical text: "0.5 amp. maximum" became "5 amp. maximum" because "0." matched the marker pattern. The bug class is prefix regexes that do not assert what comes after the match. One negative lookahead fixes it.
The bug class
Any pipeline that converts visual lines into semantic lists needs two operations: detect that a line starts a list item, and strip the marker from the output text.
Both are usually regexes like this:
const ORDERED_STRIP_RE = /^(?:\d{1,3}[.)]\s|[a-zA-Z][.)]\s)/;
The pattern reads "digits, then a dot or paren, then optional whitespace." The \s* is the trap. Optional whitespace means the pattern matches even when the next character is another digit. "0.5 amp" begins with 0., which satisfies \d{1,3}[.)] followed by zero spaces.
Why the language produces it
Two forces create this bug. First, \s* instead of \s+ is usually deliberate: markers arrive as isolated tokens ("3." with nothing after it) when the PDF emits the marker as its own text item, so requiring trailing whitespace breaks legitimate cases.
Second, regexes fail open. A prefix pattern that overmatches does not throw, it just deletes characters. The corrupted string looks plausible: "5 amp. maximum humidifier" reads fine unless you know the spec said 0.5.
This is worse than a crash. It is data corruption in a document tool, the one category of error users cannot forgive.
The fix
Assert that the character after the marker punctuation is not a digit:
// (?!\d) prevents decimals ("0.5 amp") from being read as marker "0."
const ORDERED_STRIP_RE =
/^(?:\d{1,3}.)\s*|[a-zA-Z].)|[ivxIVX]+.))/;
Three details:
(?!\d)after the numeric branch kills the decimal collision while still matching bare "3." tokens.- The letter branches got
(?:\s+|$)instead of\s*: a letter marker with no following space is almost always an abbreviation ("e.g.", "U.S."), so those branches should demand whitespace or end of string. - The same lookahead must go everywhere the pattern is duplicated. Ours lived in three places: line classification, marker stripping, and a second-pass list builder. The bug survived the first fix because the second copy still had the old pattern.
Preventing the class
Treat every prefix-stripping regex as a two-sided contract: it must match the prefix AND assert the boundary after it. ^\d+[.)] is half a pattern. ^\d+.) is a whole one.
A cheap audit: grep your codebase for replace(/^ and check each pattern for a boundary assertion. Any pattern ending in \s deserves suspicion, because \s matches the empty string and therefore asserts nothing.
The lesson
Regexes that delete text should be held to a higher standard than regexes that detect text. Detection errors show up as misclassification you can see. Deletion errors show up as subtly wrong numbers in someone's furnace manual.