@pipeworx/mcp-edge-case-museum 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -12
- package/bin/cli.js +55 -11
- package/lib/index.js +333 -0
- package/package.json +17 -16
- package/server.json +1 -1
- package/tools.json +98 -0
- package/src/bake.mjs +0 -112
- package/src/ecm-index-data.ts +0 -15
- package/src/exhibits/datetime/add-24-hours-vs-add-1-day-across-dst.json +0 -51
- package/src/exhibits/datetime/add-365-days-to-feb-29-not-next-feb-29.json +0 -49
- package/src/exhibits/datetime/age-calculator-feb29-birthday-crash-vs-silent.json +0 -52
- package/src/exhibits/datetime/century-leap-year-1900-not-leap.json +0 -68
- package/src/exhibits/datetime/datetime-utcnow-naive-deprecated.json +0 -39
- package/src/exhibits/datetime/dst-fall-back-ambiguous-fold.json +0 -51
- package/src/exhibits/datetime/dst-spring-forward-nonexistent-time.json +0 -57
- package/src/exhibits/datetime/fractional-utc-offsets-kathmandu-kolkata.json +0 -50
- package/src/exhibits/datetime/iso-week-year-vs-calendar-year.json +0 -48
- package/src/exhibits/datetime/js-date-field-overflow-rollover.json +0 -49
- package/src/exhibits/datetime/leap-second-rejected-vs-silently-invalid.json +0 -57
- package/src/exhibits/datetime/lord-howe-30-minute-dst.json +0 -47
- package/src/exhibits/datetime/midnight-that-doesnt-exist-sao-paulo.json +0 -53
- package/src/exhibits/datetime/python-naive-aware-comparison-typeerror.json +0 -46
- package/src/exhibits/datetime/samoa-skipped-day-2011.json +0 -50
- package/src/exhibits/datetime/southern-hemisphere-dst-reversed.json +0 -50
- package/src/exhibits/datetime/two-digit-year-century-pivot-mismatch.json +0 -49
- package/src/exhibits/datetime/year-2038-int32-overflow.json +0 -52
- package/src/exhibits/filenames/absolute-second-argument-resets-join.json +0 -62
- package/src/exhibits/filenames/bidi-override-extension-spoof.json +0 -58
- package/src/exhibits/filenames/case-insensitive-filesystem-pure-path-mismatch.json +0 -74
- package/src/exhibits/filenames/dotfile-has-no-extension.json +0 -61
- package/src/exhibits/filenames/extension-trap-double-extension.json +0 -62
- package/src/exhibits/filenames/filename-length-limits-bytes-not-chars.json +0 -66
- package/src/exhibits/filenames/leading-dash-filename-parsed-as-flag.json +0 -67
- package/src/exhibits/filenames/nfc-nfd-filename-lookup-mismatch.json +0 -68
- package/src/exhibits/filenames/ntfs-alternate-data-stream-colon.json +0 -59
- package/src/exhibits/filenames/ntpath-splitdrive-unc-paths.json +0 -58
- package/src/exhibits/filenames/nul-byte-in-filename-raises-at-open.json +0 -60
- package/src/exhibits/filenames/path-join-escapes-the-base-directory.json +0 -63
- package/src/exhibits/filenames/windows-backslash-traversal-ntpath.json +0 -62
- package/src/exhibits/filenames/windows-reserved-device-names.json +0 -73
- package/src/exhibits/filenames/windows-trailing-dot-space-stripped.json +0 -77
- package/src/exhibits/float-money/decimal-from-float-vs-string.json +0 -66
- package/src/exhibits/float-money/float-anchor-0-1-plus-0-2.json +0 -67
- package/src/exhibits/float-money/intl-number-format-de-de-roundtrip-misparsed.json +0 -62
- package/src/exhibits/float-money/iso4217-minor-units-not-always-two.json +0 -78
- package/src/exhibits/float-money/math-abs-negative-zero.json +0 -51
- package/src/exhibits/float-money/max-safe-integer-plus-two.json +0 -62
- package/src/exhibits/float-money/naive-loop-sum-order-dependence.json +0 -71
- package/src/exhibits/float-money/nan-breaks-equality-and-max-min.json +0 -70
- package/src/exhibits/float-money/negative-zero-balance-formatting.json +0 -74
- package/src/exhibits/float-money/number-epsilon-false-positive-small-magnitude.json +0 -52
- package/src/exhibits/float-money/parsefloat-comma-thousands-separator.json +0 -66
- package/src/exhibits/float-money/python-banker-rounding-vs-js-round-half-up.json +0 -65
- package/src/exhibits/float-money/python-round-2-675-float-imprecision.json +0 -64
- package/src/exhibits/float-money/splitting-100-dollars-three-ways-leaves-a-cent.json +0 -63
- package/src/exhibits/float-money/sum-ten-dimes-not-a-dollar.json +0 -77
- package/src/exhibits/float-money/tofixed-1-005-rounds-down-not-up.json +0 -63
- package/src/exhibits/float-money/tofixed-2-345-inconsistent-rounding.json +0 -64
- package/src/exhibits/integers/array-index-above-2-32-2-not-an-index.json +0 -42
- package/src/exhibits/integers/bigint-number-mixing-typeerror.json +0 -49
- package/src/exhibits/integers/bitwise-32-bit-truncation.json +0 -56
- package/src/exhibits/integers/bool-is-int-subclass-dict-key-collision.json +0 -58
- package/src/exhibits/integers/floor-div-mod-sign-python-vs-js.json +0 -48
- package/src/exhibits/integers/json-parse-loses-id-precision.json +0 -58
- package/src/exhibits/integers/max-safe-integer-plus-one-equals-plus-two.json +0 -58
- package/src/exhibits/integers/number-empty-and-whitespace-string-equals-zero.json +0 -55
- package/src/exhibits/integers/parseint-null-radix36-digit-soup.json +0 -57
- package/src/exhibits/integers/parseint-number-argument-scientific-notation.json +0 -48
- package/src/exhibits/integers/parseint-stops-at-first-invalid-char-vs-number.json +0 -58
- package/src/exhibits/integers/python-int-accepts-fullwidth-unicode-digits.json +0 -56
- package/src/exhibits/integers/python-int-accepts-underscores-and-whitespace.json +0 -48
- package/src/exhibits/integers/python-unbounded-int-vs-fixed-width-wrap.json +0 -49
- package/src/exhibits/names-addresses/address-with-no-street-number.json +0 -54
- package/src/exhibits/names-addresses/ascii-regex-rejects-accented-and-hyphenated-names.json +0 -55
- package/src/exhibits/names-addresses/house-number-suffix-collapsed-by-digit-extraction.json +0 -55
- package/src/exhibits/names-addresses/mononym-fails-first-last-required.json +0 -56
- package/src/exhibits/names-addresses/naive-lowercase-breaks-greek-final-sigma.json +0 -64
- package/src/exhibits/names-addresses/null-surname-coerced-to-null-value.json +0 -58
- package/src/exhibits/names-addresses/single-letter-surname-fails-min-length.json +0 -55
- package/src/exhibits/names-addresses/sort-by-surname-particle-convention-mismatch.json +0 -57
- package/src/exhibits/names-addresses/spanish-double-surname-treated-as-single.json +0 -55
- package/src/exhibits/names-addresses/split-surname-on-last-space-loses-particle.json +0 -59
- package/src/exhibits/names-addresses/title-case-mangles-mixed-case-and-apostrophes.json +0 -60
- package/src/exhibits/names-addresses/uk-canada-postal-code-assumed-numeric.json +0 -55
- package/src/exhibits/names-addresses/us-zip-code-leading-zero-lost-as-integer.json +0 -58
- package/src/exhibits/names-addresses/varchar-truncation-splits-name-mid-grapheme.json +0 -63
- package/src/exhibits/parsing/csv-crlf-trailing-blank-line.json +0 -87
- package/src/exhibits/parsing/csv-doubled-quote-escaping.json +0 -70
- package/src/exhibits/parsing/csv-formula-injection-excel.json +0 -71
- package/src/exhibits/parsing/csv-quoted-field-with-comma.json +0 -70
- package/src/exhibits/parsing/csv-quoted-field-with-newline.json +0 -70
- package/src/exhibits/parsing/csv-semicolon-delimited-european-excel.json +0 -70
- package/src/exhibits/parsing/csv-utf8-bom-first-header.json +0 -74
- package/src/exhibits/parsing/email-regex-rejects-valid-apostrophe-address.json +0 -68
- package/src/exhibits/parsing/json-big-integer-precision-loss.json +0 -65
- package/src/exhibits/parsing/json-duplicate-keys-last-wins.json +0 -64
- package/src/exhibits/parsing/json-lone-surrogate-roundtrip.json +0 -58
- package/src/exhibits/parsing/json-nan-infinity-not-valid-json.json +0 -54
- package/src/exhibits/parsing/url-backslash-treated-as-slash-whatwg.json +0 -60
- package/src/exhibits/parsing/url-urlparse-never-raises-on-garbage.json +0 -60
- package/src/exhibits/parsing/url-userinfo-trusted-looking-host.json +0 -60
- package/src/exhibits/sorting/accented-letters-sort-after-z.json +0 -63
- package/src/exhibits/sorting/byte-order-caps-before-lowercase.json +0 -66
- package/src/exhibits/sorting/case-insensitive-sort-lower-vs-casefold.json +0 -56
- package/src/exhibits/sorting/comparator-returns-boolean-js.json +0 -49
- package/src/exhibits/sorting/js-array-sort-stringifies-numbers.json +0 -68
- package/src/exhibits/sorting/locale-collation-de-vs-sv.json +0 -54
- package/src/exhibits/sorting/localecompare-numeric-option.json +0 -50
- package/src/exhibits/sorting/nan-in-sort.json +0 -59
- package/src/exhibits/sorting/natural-sort-breaks-lexicographic.json +0 -63
- package/src/exhibits/sorting/postgres-collation-documented.json +0 -61
- package/src/exhibits/sorting/python-cannot-compare-str-int.json +0 -70
- package/src/exhibits/sorting/reverse-sort-vs-reverse-after-sort-ties.json +0 -48
- package/src/exhibits/sorting/sorting-mixed-none-null.json +0 -56
- package/src/exhibits/sorting/typed-array-sort-is-numeric-by-default.json +0 -48
- package/src/exhibits/unicode/arabic-indic-digit-parses.json +0 -73
- package/src/exhibits/unicode/bidi-override-filename-spoof.json +0 -62
- package/src/exhibits/unicode/cyrillic-homoglyph-paypal-spoof.json +0 -67
- package/src/exhibits/unicode/fullwidth-bypasses-keyword-filter.json +0 -61
- package/src/exhibits/unicode/german-eszett-case-fold.json +0 -72
- package/src/exhibits/unicode/grapheme-vs-codepoint-length.json +0 -57
- package/src/exhibits/unicode/greek-final-sigma-casefold.json +0 -54
- package/src/exhibits/unicode/js-word-regex-ascii-only.json +0 -64
- package/src/exhibits/unicode/nfc-nfd-same-name-two-accounts.json +0 -70
- package/src/exhibits/unicode/nfkc-compatibility-folding.json +0 -63
- package/src/exhibits/unicode/truncation-orphans-combining-mark.json +0 -62
- package/src/exhibits/unicode/truncation-splits-surrogate-pair.json +0 -65
- package/src/exhibits/unicode/turkish-dotless-i-case-mapping.json +0 -72
- package/src/exhibits/unicode/zalgo-combining-mark-stacking.json +0 -57
- package/src/exhibits/unicode/zero-width-space-username.json +0 -62
- package/src/harness.mjs +0 -331
- package/src/index.ts +0 -686
- package/src/server.ts +0 -49
- package/src/verified.json +0 -977
- package/tsconfig.json +0 -18
package/src/ecm-index-data.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// GENERATED by src/bake.mjs from src/exhibits/ — do not hand-edit; re-run the script.
|
|
2
|
-
// 122 exhibits (datetime 18, filenames 15, float-money 17, integers 14, names-addresses 14, parsing 15, sorting 14, unicode 15) · repros verified with Python 3.14.2 and Node.js v22.22.2
|
|
3
|
-
// Registered in workers/gateway/src/pack-baked-indexes.json; the pack reads it as args._bakedIndex and never value-imports this module.
|
|
4
|
-
|
|
5
|
-
export interface Repro { language: string; status: 'verified' | 'documented'; code?: string; expected_output?: string; verified_with?: string; note?: string; source?: string }
|
|
6
|
-
export interface TestCase { inputs: unknown[]; relation: 'equal' | 'not_equal' | 'returns' | 'no_crash' | 'raises'; expected?: unknown; note?: string }
|
|
7
|
-
export interface Exhibit {
|
|
8
|
-
id: string; title: string; category: string; assumption: string; input: { display: string; description: string };
|
|
9
|
-
what_goes_wrong: string; why: string; affects: string[]; repros: Repro[]; fix: string; related: string[];
|
|
10
|
-
sources: { title: string; url: string }[]; tags: string[]; test_cases: TestCase[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const DATA_AS_OF = "2026-10-13";
|
|
14
|
-
export const RUNTIMES: Record<string, string> = {"python":"Python 3.14.2","javascript":"Node.js v22.22.2"};
|
|
15
|
-
export const EXHIBITS: Exhibit[] = [{"id":"add-24-hours-vs-add-1-day-across-dst","title":"In Python, 'add 24 hours' and 'add 1 day' land on the same wall-clock minute across a DST jump","category":"datetime","assumption":"Adding a timedelta of 24 hours to a timezone-aware datetime moves it forward by exactly 24 real hours, the same way adding 1 day does if you just mean 'tomorrow'.","input":{"display":"datetime(2024, 3, 9, 12, 0, tzinfo=ZoneInfo(\"America/New_York\")) + timedelta(hours=24) vs + timedelta(days=1)","description":"A 'remind me in 24 hours' feature, or a billing-cycle / subscription renewal computed by adding a fixed duration to a timestamp, run across the March DST change."},"what_goes_wrong":"A 'remind me in exactly 24 hours' feature built on Python's aware-datetime arithmetic fires at the same wall-clock minute as 'remind me tomorrow', not 24 real hours later -- on the spring-forward day the two features that were supposed to differ by design turn out identical, and neither one is actually 24 elapsed hours.","why":"CPython's datetime arithmetic for aware datetimes adds the timedelta to the naive (wall-clock) fields first, then reinterprets the result in the same zone -- it does not convert to UTC, add, and convert back. So timedelta(hours=24) and timedelta(days=1) both just add to the naive calendar fields and land on the same wall-clock time, 2024-03-10 12:00, even though only 23 real hours separate the two instants because of the lost hour. Subtraction follows the same rule: subtracting two datetimes that share a tzinfo returns the wall-clock difference (1 day here, not the 23 real hours); Python only goes through UTC when the operands have different tzinfo objects. JavaScript's Date stores a UTC millisecond count, so adding 24*3600*1000 to getTime() is 24 real elapsed hours and renders as 13:00, not 12:00, in the DST-affected zone (the repro). JavaScript also has wall-clock arithmetic, but only in the host's local zone and only through the local setters: in a process running in America/New_York, both setDate(getDate() + 1) and setHours(getHours() + 24) land on 12:00 the next day, 23 real hours later, just like Python.","affects":["python","javascript (Date local-time setters such as setHours/setDate)"],"repros":[{"language":"python","status":"verified","code":"from zoneinfo import ZoneInfo\nfrom datetime import datetime, timedelta\n\ntz = ZoneInfo(\"America/New_York\")\nstart = datetime(2024, 3, 9, 12, 0, tzinfo=tz)\nplus_24h = start + timedelta(hours=24)\nplus_1day = start + timedelta(days=1)\nprint(\"start:\", start, \"| +24h:\", plus_24h, \"| +1day:\", plus_1day)\nprint(\"+24h == +1day (same wall clock):\", plus_24h == plus_1day)\nprint(\"+24h in UTC:\", plus_24h.astimezone(ZoneInfo(\"UTC\")))\nprint(\"real elapsed hours:\", (plus_24h.astimezone(ZoneInfo(\"UTC\")) - start.astimezone(ZoneInfo(\"UTC\"))).total_seconds() / 3600)\n","expected_output":"start: 2024-03-09 12:00:00-05:00 | +24h: 2024-03-10 12:00:00-04:00 | +1day: 2024-03-10 12:00:00-04:00\n+24h == +1day (same wall clock): True\n+24h in UTC: 2024-03-10 16:00:00+00:00\nreal elapsed hours: 23.0","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// JS Date arithmetic is always real elapsed time (UTC milliseconds).\nconst startUtc = Date.UTC(2024, 2, 9, 17, 0, 0); // 2024-03-09 12:00 America/New_York\nconst plus24hUtc = startUtc + 24 * 3600 * 1000;\nconst fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });\nconsole.log('start local:', fmt.format(new Date(startUtc)));\nconsole.log('start + 24 real hours, local:', fmt.format(new Date(plus24hUtc)));\n","expected_output":"start local: 03/09/2024, 12:00\nstart + 24 real hours, local: 03/10/2024, 13:00","verified_with":"Node.js v22.22.2"}],"fix":"Decide explicitly whether you mean 'N real hours of elapsed time' (convert to UTC, add, convert back -- or just do the arithmetic in UTC) or 'the same wall-clock time N calendar days later' (use calendar-aware addition that is documented to do that, e.g. `dateutil.relativedelta`). Never assume Python's aware `timedelta` arithmetic gives you the first one; it gives you something closer to the second.","related":["dst-spring-forward-nonexistent-time"],"sources":[{"title":"Python docs: datetime -- Supported operations","url":"https://docs.python.org/3/library/datetime.html#datetime.datetime"},{"title":"IANA Time Zone Database","url":"https://www.iana.org/time-zones"}],"tags":["timezone","scheduling","reminder","timestamps"],"test_cases":[]},{"id":"add-365-days-to-feb-29-not-next-feb-29","title":"A Feb-29 birthday plus 365 days is Feb 28 of the next year, not another Feb 29","category":"datetime","assumption":"Adding 365 days to a date gives you 'the same date, one year later'.","input":{"display":"date(2000, 2, 29) + timedelta(days=365)","description":"A 'renews annually' or '1 year from now' feature implemented as 'add 365 days' instead of 'add 1 to the year field'."},"what_goes_wrong":"A subscription or anniversary calculated as 'start date + timedelta(days=365)' for someone whose start date is February 29 lands on February 28 the following year, one day short of their actual next anniversary (which, in a non-leap year, does not exist on the calendar at all).","why":"`timedelta(days=365)` counts elapsed days; it has no concept of 'the same date next year'. The 365 days after 2000-02-29 run from March 2000 through February 2001, a stretch with no leap day in it, so they end on 2001-02-28; 2001-03-01 is 366 days away. The same drift hits any date in a leap year that falls before Feb 29: 2024-01-15 + 365 days is 2025-01-14, not 2025-01-15.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from datetime import date, timedelta\n\nstart = date(2000, 2, 29)\nplus_365 = start + timedelta(days=365)\nprint(\"start:\", start, \"| +365 days:\", plus_365)\n","expected_output":"start: 2000-02-29 | +365 days: 2001-02-28","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const start = new Date(Date.UTC(2000, 1, 29));\nconst plus365 = new Date(start.getTime() + 365 * 86400 * 1000);\nconsole.log('start:', start.toISOString().slice(0, 10), '| +365 days:', plus365.toISOString().slice(0, 10));\n","expected_output":"start: 2000-02-29 | +365 days: 2001-02-28","verified_with":"Node.js v22.22.2"}],"fix":"Decide explicitly whether 'in N years' means 'add N to the year field, clamping Feb 29 to Feb 28 (or rolling to Mar 1) if needed' or 'add exactly N*365.25 days'; use a calendar-aware date library's year-addition function (e.g. `dateutil.relativedelta`) rather than `timedelta(days=365)` for anything billed as 'annual'.","related":["century-leap-year-1900-not-leap","age-calculator-feb29-birthday-crash-vs-silent"],"sources":[{"title":"Python docs: datetime -- timedelta objects","url":"https://docs.python.org/3/library/datetime.html#timedelta-objects"}],"tags":["date parsing","scheduling","age calculation","subscription","billing"],"test_cases":[]},{"id":"age-calculator-feb29-birthday-crash-vs-silent","title":"Computing 'this year's birthday' for someone born on Feb 29 crashes in Python and silently becomes March 1 in JS","category":"datetime","assumption":"You can always construct 'this year's version' of someone's birthday by combining the current year with their birth month and day.","input":{"display":"date(2023, 2, 29) (constructing the 2023 birthday of someone born 2000-02-29, where 2023 is not a leap year)","description":"Anniversary/birthday reminder features, age-at-next-birthday calculations, and loyalty-program 'birthday month' logic for anyone born on February 29."},"what_goes_wrong":"A 'days until your next birthday' feature for a Feb-29 user either crashes outright (Python) or silently reports their birthday as March 1st (JavaScript) in every year that isn't a leap year -- and the two backends disagree with each other about which day that is.","why":"February 29 only exists in leap years. Python's `date()` constructor validates the day against the given year and raises `ValueError` for `date(2023, 2, 29)` because 2023 is not a leap year. JavaScript's `Date` constructor performs no such validation: field overflow silently rolls forward, so `new Date(2023, 1, 29)` (month index 1 = February) normalizes to March 1, 2023. Neither answer is 'the real February 29 of 2023' because there isn't one; the two runtimes just fail in opposite styles, loud versus silent.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from datetime import date\n\nbirth = date(2000, 2, 29)\nreference_year = 2023 # not a leap year\ntry:\n this_years_birthday = date(reference_year, birth.month, birth.day)\n print(\"birthday:\", this_years_birthday)\nexcept ValueError as e:\n print(\"error constructing this year's birthday:\", e)\n","expected_output":"error constructing this year's birthday: day 29 must be in range 1..28 for month 2 in year 2023","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const birthMonth = 1; // February, 0-indexed\nconst birthDay = 29;\nconst referenceYear = 2023; // not a leap year\nconst thisYearsBirthday = new Date(referenceYear, birthMonth, birthDay);\nconsole.log('constructed date:', thisYearsBirthday.getFullYear(), thisYearsBirthday.getMonth(), thisYearsBirthday.getDate());\n","expected_output":"constructed date: 2023 2 1","verified_with":"Node.js v22.22.2"}],"fix":"Decide and document a policy for Feb-29 anniversaries in non-leap years (commonly Feb 28 or Mar 1) and apply it explicitly -- check `calendar.isleap(year)` first and substitute the chosen fallback day, rather than letting the date constructor decide by crashing or overflowing.","related":["add-365-days-to-feb-29-not-next-feb-29","century-leap-year-1900-not-leap"],"sources":[{"title":"Python docs: datetime.date","url":"https://docs.python.org/3/library/datetime.html#datetime.date"},{"title":"ECMA-262: Date constructor (field overflow)","url":"https://tc39.es/ecma262/#sec-date-constructor"}],"tags":["age calculation","date parsing","validation","reminder"],"test_cases":[]},{"id":"century-leap-year-1900-not-leap","title":"1900 was not a leap year, even though it's divisible by 4","category":"datetime","assumption":"A year is a leap year exactly when it's divisible by 4.","input":{"display":"year % 4 == 0 for 1900 (True) vs the Gregorian rule's actual answer (False)","description":"A hand-rolled leap-year check (common in date-math utilities, age calculators, or anything that didn't reach for the standard library) applied to any century year."},"what_goes_wrong":"A hand-written `is_leap(y) = y % 4 == 0` check says 1900 and 2100 are leap years; both are not, and code that trusts the check will construct a nonexistent February 29 or miscount days in centuries not divisible by 400.","why":"The Gregorian calendar's actual rule is: divisible by 4 AND (not divisible by 100 OR divisible by 400). Century years are leap only if divisible by 400 -- 1900 and 2100 fail that (divisible by 100, not by 400) and are NOT leap years, while 2000 passes (divisible by 400) and IS one. The simpler 'divisible by 4' rule most developers remember is only an approximation: it is right for 397 of every 400 years and silently wrong for exactly these three century years in each 400.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import calendar\n\ndef naive_is_leap(y):\n return y % 4 == 0\n\nfor y in (1900, 2000, 2024, 2100):\n print(y, \"naive:\", naive_is_leap(y), \"actual:\", calendar.isleap(y))\n\ntry:\n from datetime import date\n date(1900, 2, 29)\nexcept ValueError as e:\n print(\"constructing Feb 29, 1900 raises:\", e)\n","expected_output":"1900 naive: True actual: False\n2000 naive: True actual: True\n2024 naive: True actual: True\n2100 naive: True actual: False\nconstructing Feb 29, 1900 raises: day 29 must be in range 1..28 for month 2 in year 1900","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function naiveIsLeap(y) { return y % 4 === 0; }\nfunction actualIsLeap(y) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; }\nfor (const y of [1900, 2000, 2024, 2100]) {\n console.log(y, 'naive:', naiveIsLeap(y), 'actual:', actualIsLeap(y));\n}\n// new Date silently rolls Feb 29 of a non-leap century year into March 1\nconst d = new Date(1900, 1, 29);\nconsole.log('new Date(1900, 1, 29) ->', d.getFullYear(), d.getMonth(), d.getDate());\n","expected_output":"1900 naive: true actual: false\n2000 naive: true actual: true\n2024 naive: true actual: true\n2100 naive: true actual: false\nnew Date(1900, 1, 29) -> 1900 2 1","verified_with":"Node.js v22.22.2"}],"fix":"Use the standard library's leap-year check (Python `calendar.isleap`, or construct the date and catch the error) instead of a hand-rolled `% 4` test. If you must write it yourself, the full rule is `(y % 4 == 0 and y % 100 != 0) or y % 400 == 0`.","related":["add-365-days-to-feb-29-not-next-feb-29"],"sources":[{"title":"Python docs: calendar.isleap","url":"https://docs.python.org/3/library/calendar.html#calendar.isleap"},{"title":"ISO 8601 / Gregorian calendar leap year rule (Wikipedia, background)","url":"https://en.wikipedia.org/wiki/Leap_year#Gregorian_calendar"}],"tags":["date parsing","validation","scheduling","leap year"],"test_cases":[{"inputs":[1900],"relation":"returns","expected":false,"note":"A correct is_leap_year(1900) must return false."},{"inputs":[2000],"relation":"returns","expected":true,"note":"A correct is_leap_year(2000) must return true."}]},{"id":"datetime-utcnow-naive-deprecated","title":"datetime.utcnow() returns a value that is UTC in meaning but naive in type -- and is deprecated as of Python 3.12 because of it","category":"datetime","assumption":"A function named utcnow() returns a timezone-aware UTC timestamp.","input":{"display":"datetime.utcnow().tzinfo is None","description":"Any codebase that used datetime.utcnow() to get 'the current UTC time' and then mixed the result with timezone-aware datetimes elsewhere."},"what_goes_wrong":"Code that calls `datetime.utcnow()` expecting a timezone-aware UTC timestamp gets a naive one instead, which then either silently gets treated as local time by something downstream (for example `.timestamp()`, which assumes a naive datetime is local time), or raises `TypeError` the moment it's compared against an aware datetime.","why":"`datetime.utcnow()` returns the current UTC time's wall-clock fields but deliberately attaches no tzinfo, making the result naive -- indistinguishable, by type, from a naive datetime that means local time or means nothing in particular. As of Python 3.12 it is deprecated and emits a `DeprecationWarning` (as does its sibling `datetime.utcfromtimestamp()`), because this mismatch between the function's name ('UTC') and its type (naive) has caused enough real bugs; on 3.11 and earlier it runs silently. The documented replacement is `datetime.now(timezone.utc)` (or `datetime.now(datetime.UTC)` on 3.11+), which returns an aware datetime that carries its UTC-ness in the object.","affects":["python"],"repros":[{"language":"python","status":"verified","code":"import warnings\nfrom datetime import datetime, timezone\n\nwith warnings.catch_warnings(record=True) as caught:\n warnings.simplefilter(\"always\")\n naive_utc = datetime.utcnow()\n print(\"utcnow() tzinfo:\", naive_utc.tzinfo)\n print(\"warning category raised:\", [w.category.__name__ for w in caught])\n\naware_utc = datetime.now(timezone.utc)\nprint(\"now(timezone.utc) tzinfo:\", aware_utc.tzinfo)\n","expected_output":"utcnow() tzinfo: None\nwarning category raised: ['DeprecationWarning']\nnow(timezone.utc) tzinfo: UTC","verified_with":"Python 3.14.2"}],"fix":"Use `datetime.now(timezone.utc)` instead of `datetime.utcnow()`; the result carries its own UTC-ness so it can be safely compared against, and converted from, any other aware datetime.","related":["python-naive-aware-comparison-typeerror"],"sources":[{"title":"Python docs: datetime.utcnow (deprecated since 3.12)","url":"https://docs.python.org/3/library/datetime.html#datetime.datetime.utcnow"}],"tags":["timezone","timestamps","database"],"test_cases":[]},{"id":"dst-fall-back-ambiguous-fold","title":"1:30 AM on November 3, 2024 happens twice in New York, and fold picks which one","category":"datetime","assumption":"A local wall-clock time plus a timezone name uniquely identifies one instant.","input":{"display":"datetime(2024, 11, 3, 1, 30, tzinfo=ZoneInfo(\"America/New_York\"), fold=0) vs fold=1","description":"Log timestamps, scheduled job times, or user-entered times that fall in the one-hour window a clock repeats when a timezone falls back off DST."},"what_goes_wrong":"Two events both logged as '2024-11-03 01:30 America/New_York' can be a full hour apart in real time, and nothing about the string itself says which.","why":"On 2024-11-03 the America/New_York clock reaches 01:59:59 EDT, then repeats back to 01:00:00, now as EST, before continuing to 02:00. The wall-clock hour 01:00-01:59 therefore occurs twice, at two different UTC instants one hour apart. PEP 495 (Python 3.6) added a `fold` attribute to Python's datetime specifically for this: fold=0 (the default) means 'the first occurrence' (-04:00, EDT), fold=1 means 'the second occurrence' (-05:00, EST). Two systems that both construct 01:30 without agreeing on fold silently disagree by exactly one hour.","affects":["python"],"repros":[{"language":"python","status":"verified","code":"from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ntz = ZoneInfo(\"America/New_York\")\nfirst = datetime(2024, 11, 3, 1, 30, tzinfo=tz, fold=0)\nsecond = datetime(2024, 11, 3, 1, 30, tzinfo=tz, fold=1)\nprint(first.utcoffset(), second.utcoffset())\nprint(first.astimezone(ZoneInfo(\"UTC\")))\nprint(second.astimezone(ZoneInfo(\"UTC\")))\n","expected_output":"-1 day, 20:00:00 -1 day, 19:00:00\n2024-11-03 05:30:00+00:00\n2024-11-03 06:30:00+00:00","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// JS Date has no fold concept: the only way to land on either occurrence\n// is to already know the UTC instant. Walking UTC minutes shows local time\n// repeating 01:xx twice with no local-only way to tell which pass you're on.\nconst dtf = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour12: false, hour: '2-digit', minute: '2-digit' });\nfor (const h of [4, 5, 6]) {\n const utcMs = Date.UTC(2024, 10, 3, h, 30, 0);\n console.log(h + ':30 UTC ->', dtf.format(new Date(utcMs)));\n}\n","expected_output":"4:30 UTC -> 00:30\n5:30 UTC -> 01:30\n6:30 UTC -> 01:30","verified_with":"Node.js v22.22.2"}],"fix":"Store instants in UTC, never a bare local wall-clock string, especially around DST transitions. If you must accept or log an ambiguous local time, record the UTC offset or the fold/occurrence alongside it, not just the wall-clock string.","related":["dst-spring-forward-nonexistent-time"],"sources":[{"title":"PEP 495 -- Local Time Disambiguation","url":"https://peps.python.org/pep-0495/"},{"title":"Python docs: datetime.fold","url":"https://docs.python.org/3/library/datetime.html#datetime.datetime.fold"}],"tags":["timezone","scheduling","timestamps","logging","date parsing"],"test_cases":[]},{"id":"dst-spring-forward-nonexistent-time","title":"2:30 AM on March 10, 2024 never happened in New York, but your code builds it anyway","category":"datetime","assumption":"Every combination of year/month/day/hour/minute you can type into a date constructor corresponds to a real moment in that timezone.","input":{"display":"datetime(2024, 3, 10, 2, 30, tzinfo=ZoneInfo(\"America/New_York\"))","description":"A scheduled reminder, cron-style job, or appointment time entered as a local wall-clock time on the day a timezone springs forward."},"what_goes_wrong":"A reminder set for 2:30 AM on the spring-forward day is silently given the pre-transition offset (-05:00), so it fires at 07:30 UTC, which the wall clock shows as 03:30 EDT -- and nothing tells you the time you asked for never existed.","why":"On 2024-03-10 in America/New_York, clocks jump from 01:59:59 directly to 03:00:00, so every wall-clock time from 02:00 to 02:59 does not exist locally that day. Python's zoneinfo does not reject the construction. Under PEP 495, a time in a gap with the default fold=0 is interpreted with the offset in effect before the transition, so 02:30 gets -05:00 (EST) and converts to 07:30 UTC, which is 03:30 EDT on the wall clock; fold=1 would use the post-transition offset instead and give 06:30 UTC, which is 01:30 EST. The object itself keeps printing 02:30 until you round-trip it through UTC. JavaScript lands in the same place: ECMA-262 requires a skipped local time to be interpreted with the offset before the transition, so `new Date(2024, 2, 10, 2, 30)` in a process running in America/New_York is also 07:30 UTC. Neither language raises.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ntz = ZoneInfo(\"America/New_York\")\ngap = datetime(2024, 3, 10, 2, 30, tzinfo=tz)\nprint(gap, gap.utcoffset())\nprint(gap.astimezone(ZoneInfo(\"UTC\")))\n","expected_output":"2024-03-10 02:30:00-05:00 -1 day, 19:00:00\n2024-03-10 07:30:00+00:00","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"process.env.TZ = 'America/New_York'; // Node re-reads TZ when it is set at runtime\nconst gap = new Date(2024, 2, 10, 2, 30); // 02:30 local on the spring-forward day\nconsole.log('new Date(2024, 2, 10, 2, 30) ->', gap.toISOString());\nconsole.log('local wall clock:', gap.getHours() + ':' + String(gap.getMinutes()).padStart(2, '0'));\n// Walk UTC minutes through the gap: the wall clock jumps from 01:55 to 03:00.\nconst dtf = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour12: false, hour: '2-digit', minute: '2-digit' });\nfor (const m of [55, 60]) {\n console.log('06:00 UTC +', m, 'min ->', dtf.format(new Date(Date.UTC(2024, 2, 10, 6, m, 0))));\n}","expected_output":"new Date(2024, 2, 10, 2, 30) -> 2024-03-10T07:30:00.000Z\nlocal wall clock: 3:30\n06:00 UTC + 55 min -> 01:55\n06:00 UTC + 60 min -> 03:00","verified_with":"Node.js v22.22.2"}],"fix":"Store and schedule in UTC; only render to local wall-clock at display time. If you must accept a local wall-clock input, detect the gap explicitly and surface it to the user rather than silently picking a side: with zoneinfo, a time is nonexistent when `dt.astimezone(timezone.utc).astimezone(tz).replace(tzinfo=None) != dt.replace(tzinfo=None)`; with pytz, `tz.localize(naive, is_dst=None)` raises `NonExistentTimeError`.","related":["dst-fall-back-ambiguous-fold","midnight-that-doesnt-exist-sao-paulo"],"sources":[{"title":"PEP 495 -- Local Time Disambiguation","url":"https://peps.python.org/pep-0495/"},{"title":"Python docs: zoneinfo","url":"https://docs.python.org/3/library/zoneinfo.html"},{"title":"IANA Time Zone Database","url":"https://www.iana.org/time-zones"}],"tags":["timezone","scheduling","reminder","date parsing","timestamps"],"test_cases":[]},{"id":"fractional-utc-offsets-kathmandu-kolkata","title":"Not every timezone is a whole number of hours from UTC -- Nepal is 5 hours and 45 minutes off","category":"datetime","assumption":"A UTC offset is always a whole number of hours (or at worst, whole 30-minute steps).","input":{"display":"ZoneInfo(\"Asia/Kathmandu\") -> UTC+05:45; ZoneInfo(\"Asia/Kolkata\") -> UTC+05:30","description":"Scheduling a meeting, computing 'business hours', or bucketing timestamps by local hour for users in Nepal, India, or several Pacific/Indian Ocean zones."},"what_goes_wrong":"Code that converts an offset to hours with integer math (`offset_minutes // 60`) or assumes `local_hour = (utc_hour + offset_hours) % 24` is correct silently produces times that are off by 15, 30, or 45 minutes for users in these zones.","why":"UTC offsets are defined per timezone by the IANA tz database and are not required to be whole hours. Nepal Standard Time is UTC+05:45, India Standard Time is UTC+05:30, and historically several zones have used offsets like +04:51 or +08:05:43 before standardization. Any code path that models 'timezone' as 'an integer number of hours' or truncates a fractional offset will misplace every timestamp converted through it by the truncated remainder.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\nkathmandu = datetime(2024, 6, 1, 12, 0, tzinfo=ZoneInfo(\"Asia/Kathmandu\"))\nkolkata = datetime(2024, 6, 1, 12, 0, tzinfo=ZoneInfo(\"Asia/Kolkata\"))\nprint(\"Kathmandu offset:\", kathmandu.utcoffset())\nprint(\"Kolkata offset:\", kolkata.utcoffset())\nprint(\"naive hour-only offset would truncate to:\", kathmandu.utcoffset().seconds // 3600, \"hours, losing the 45 minutes\")\n","expected_output":"Kathmandu offset: 5:45:00\nKolkata offset: 5:30:00\nnaive hour-only offset would truncate to: 5 hours, losing the 45 minutes","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function offsetMinutes(utcMs, timeZone) {\n const dtf = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false,\n year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });\n const parts = dtf.formatToParts(new Date(utcMs));\n const o = {};\n for (const p of parts) o[p.type] = p.value;\n const asUTC = Date.UTC(+o.year, +o.month - 1, +o.day, +o.hour, +o.minute, +o.second);\n return (asUTC - utcMs) / 60000;\n}\nconst t = Date.UTC(2024, 5, 1, 12, 0, 0);\nconsole.log('Kathmandu offset minutes:', offsetMinutes(t, 'Asia/Kathmandu'));\nconsole.log('Kolkata offset minutes:', offsetMinutes(t, 'Asia/Kolkata'));\n","expected_output":"Kathmandu offset minutes: 345\nKolkata offset minutes: 330","verified_with":"Node.js v22.22.2"}],"fix":"Always carry UTC offsets (and do timezone conversion) in minutes or seconds, never truncated hours, and let the timezone library (zoneinfo, Intl) compute the offset rather than hand-rolling it from a hours table.","related":["lord-howe-30-minute-dst"],"sources":[{"title":"IANA Time Zone Database","url":"https://www.iana.org/time-zones"},{"title":"Wikipedia: list of UTC offsets (background, not sole source)","url":"https://en.wikipedia.org/wiki/List_of_UTC_offsets"}],"tags":["timezone","scheduling","date parsing"],"test_cases":[]},{"id":"iso-week-year-vs-calendar-year","title":"December 30, 2024 is in ISO week 1 of 2025, not week 52 of 2024","category":"datetime","assumption":"The 'year' a date belongs to for week-numbering purposes is just its calendar year.","input":{"display":"date(2024, 12, 30).isocalendar() == (2025, 1, 1) -- ISO week-year 2025, week 1, weekday Monday","description":"Any report, invoice numbering, or analytics bucketing that labels data by 'year-week' (e.g. '2024-W52') using the calendar year instead of the ISO week-year."},"what_goes_wrong":"A 'weekly report for year X, week Y' system that pairs the calendar year with the ISO week number produces a nonsensical or wrong label like '2024-W01' for a date that is actually in the first ISO week of 2025, or silently double-counts/skips a week at the year boundary.","why":"ISO 8601 defines week 1 of a year as the week containing that year's first Thursday (equivalently, the week containing January 4th), and weeks run Monday-Sunday. Because of this rule, the last few days of December can fall into week 1 of the FOLLOWING year (as with 2024-12-30, a Monday, which starts the week containing 2025-01-02, a Thursday), and conversely the first few days of January can belong to the last week of the PREVIOUS year. The ISO week-year (`%G` in strftime, `isocalendar()[0]` in Python) is therefore a distinct value from the plain calendar year (`%Y`), and the two disagree exactly in this boundary zone.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from datetime import date\n\nd = date(2024, 12, 30)\nprint(\"calendar year:\", d.year)\nprint(\"isocalendar:\", d.isocalendar())\nprint(d.strftime(\"%G-W%V\"), \"vs plain %Y:\", d.strftime(\"%Y\"))\n","expected_output":"calendar year: 2024\nisocalendar: datetime.IsoCalendarDate(year=2025, week=1, weekday=1)\n2025-W01 vs plain %Y: 2024","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// JavaScript has no built-in ISO week function; this is the standard\n// ISO 8601 algorithm (Thursday of the week decides the week-year).\nfunction isoWeekInfo(y, m, day) {\n const d = new Date(Date.UTC(y, m, day));\n const dayNum = (d.getUTCDay() + 6) % 7; // Monday=0..Sunday=6\n d.setUTCDate(d.getUTCDate() - dayNum + 3); // Thursday of this week\n const isoYear = d.getUTCFullYear();\n const jan4 = new Date(Date.UTC(isoYear, 0, 4));\n const week = 1 + Math.round(((d - jan4) / 86400000 - 3 + ((jan4.getUTCDay() + 6) % 7)) / 7);\n return { isoYear, week };\n}\nconsole.log('calendar year:', 2024, '| ISO:', isoWeekInfo(2024, 11, 30));\n","expected_output":"calendar year: 2024 | ISO: { isoYear: 2025, week: 1 }","verified_with":"Node.js v22.22.2"}],"fix":"Always use the ISO week-year (`%G`), never the plain calendar year (`%Y`), when labeling data with an ISO week number (`%V`); most date libraries expose both separately specifically because they can diverge.","related":[],"sources":[{"title":"ISO 8601 week date (Wikipedia overview, verify against ISO 8601:2004)","url":"https://en.wikipedia.org/wiki/ISO_week_date"},{"title":"Python docs: strftime %G/%V format codes","url":"https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes"}],"tags":["date parsing","database","validation"],"test_cases":[]},{"id":"js-date-field-overflow-rollover","title":"new Date(2024, 1, 30) silently becomes March 1, not an error","category":"datetime","assumption":"Passing an out-of-range day (or month) to a date constructor either gets rejected or clamped to the nearest valid value.","input":{"display":"new Date(2024, 1, 30) -- February only has 29 days in 2024","description":"Date arithmetic done by hand on year/month/day components, e.g. 'last day of next month minus a few days', or a date picker that lets a day number outlive the month the user is also changing."},"what_goes_wrong":"Code computing a date by incrementing a day-of-month field without checking the month's length doesn't throw and doesn't clamp -- it quietly rolls over into the next month entirely, so 'February 30th' becomes 'March 1st' with no signal anything went wrong.","why":"The ECMA-262 Date constructor specifies that out-of-range field values are normalized by carrying the excess into the next larger field, rather than being validated and rejected. Day 30 is one past February's last day in 2024 (the 29th, since 2024 is a leap year), so the extra day carries into March and the result is March 1. The same arithmetic in Python's `date()` constructor raises `ValueError` instead, because Python validates each field against the actual calendar rather than normalizing it.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"const d = new Date(2024, 1, 30); // month 1 = February (0-indexed), day 30\nconsole.log(d.getFullYear(), d.getMonth(), d.getDate());\n// Also overflows the month field itself:\nconst d2 = new Date(2024, 13, 1); // month 13 doesn't exist\nconsole.log(d2.getFullYear(), d2.getMonth(), d2.getDate());\n","expected_output":"2024 2 1\n2025 1 1","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"from datetime import date\n\ntry:\n date(2024, 2, 30)\nexcept ValueError as e:\n print(\"Python raises instead of rolling over:\", e)\n","expected_output":"Python raises instead of rolling over: day 30 must be in range 1..29 for month 2 in year 2024","verified_with":"Python 3.14.2"}],"fix":"Never construct a Date from hand-computed year/month/day components without first validating the day against that month's actual length (and the month against 0-11); if you want 'last valid day of the month' semantics, compute it explicitly rather than relying on overflow behavior.","related":["century-leap-year-1900-not-leap","age-calculator-feb29-birthday-crash-vs-silent"],"sources":[{"title":"ECMA-262: Date constructor / MakeDay normalization","url":"https://tc39.es/ecma262/#sec-date-constructor"},{"title":"MDN: Date() constructor","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date"}],"tags":["date parsing","validation"],"test_cases":[]},{"id":"leap-second-rejected-vs-silently-invalid","title":"A ':60' leap-second timestamp raises in Python, is an Invalid Date in JS -- and V8's fallback parser silently turns it into 23:59:00","category":"datetime","assumption":"A timestamp string like 2016-12-31T23:59:60Z, which is how official leap-second records are written, is a value your date library can parse.","input":{"display":"\"2016-12-31T23:59:60Z\" (the real UTC leap second inserted at the end of 2016)","description":"Parsing a timestamp from an NTP/GPS log, a scientific instrument feed, or any system that records UTC leap seconds literally in its output, where one legitimately occurred (e.g. 2016-12-31 23:59:60 UTC)."},"what_goes_wrong":"Feeding a real, historically-occurring leap-second timestamp to a standard date parser fails three different ways: loudly in Python, as a silently invalid Date for an ISO string in JavaScript, and, for a non-ISO string in V8 (Node, Chrome), as a valid-looking time one minute too early.","why":"Python's `datetime` and the ECMAScript date-time string format both model a minute as seconds 00 through 59, with no slot for the 61st second a leap second inserts. In Python, `datetime(..., second=60)`, `datetime.strptime` and `datetime.fromisoformat` all raise `ValueError` (the older `time.strptime`, by contrast, accepts 60). ECMA-262 requires `Date.parse` to return `NaN` for a string in its ISO format with an out-of-bounds field, so `\"2016-12-31T23:59:60Z\"` yields an Invalid Date: no exception, every <, >, <=, >= comparison with it is false, and `.toISOString()` throws `RangeError` only later, at the point of use. Strings outside the ISO format go to implementation-defined fallback parsing, and there V8 accepts `\"Sat, 31 Dec 2016 23:59:60 GMT\"` and returns 2016-12-31T23:59:00Z -- a valid Date 60 seconds early, with no error at all. Other engines may differ, since that fallback is not specified.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from datetime import datetime\n\ntry:\n datetime(2016, 12, 31, 23, 59, 60)\nexcept ValueError as e:\n print(\"ValueError at construction:\", e)\n","expected_output":"ValueError at construction: second must be in 0..59, not 60","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const ms = Date.parse(\"2016-12-31T23:59:60Z\");\nconsole.log(\"Date.parse result:\", ms, \"isNaN:\", Number.isNaN(ms));\nconst d = new Date(\"2016-12-31T23:59:60Z\");\nconsole.log(\"Date object:\", d.toString(), \"| valid:\", !Number.isNaN(d.getTime()));\n// Non-ISO (RFC 2822 style) string: implementation-defined fallback parser.\nconst rfc = new Date(\"Sat, 31 Dec 2016 23:59:60 GMT\");\nconsole.log(\"V8 fallback parse of RFC 2822 ':60':\", rfc.toISOString());","expected_output":"Date.parse result: NaN isNaN: true\nDate object: Invalid Date | valid: false\nV8 fallback parse of RFC 2822 ':60': 2016-12-31T23:59:00.000Z","verified_with":"Node.js v22.22.2"}],"fix":"Never parse a raw leap-second timestamp with the standard date constructor. Detect `:60` (or `:59.999...` smeared variants, depending on the source) before parsing and either clamp it to `:59` with a documented policy or route it through a library that explicitly models leap seconds (e.g. one backed by TAI); and in JavaScript, always check `Number.isNaN(date.getTime())` immediately after any `Date.parse`/`new Date(string)` rather than trusting the object; and never hand a non-ISO timestamp string to `new Date()` / `Date.parse`, whose fallback parsing is engine-specific.","related":[],"sources":[{"title":"IETF RFC 3339: Date and Time on the Internet (leap seconds)","url":"https://www.rfc-editor.org/rfc/rfc3339#section-5.7"},{"title":"ECMA-262: Date Time String Format","url":"https://tc39.es/ecma262/#sec-date-time-string-format"},{"title":"Python docs: datetime","url":"https://docs.python.org/3/library/datetime.html"},{"title":"ECMA-262: Date.parse (non-ISO strings are implementation-defined; out-of-bounds ISO fields return NaN)","url":"https://tc39.es/ecma262/#sec-date.parse"}],"tags":["date parsing","timestamps","validation","logging"],"test_cases":[]},{"id":"lord-howe-30-minute-dst","title":"Lord Howe Island's clocks only move 30 minutes for daylight saving, not an hour","category":"datetime","assumption":"Daylight saving time always shifts the clock by exactly one hour.","input":{"display":"ZoneInfo(\"Australia/Lord_Howe\") offset: +10:30 in southern winter (July), +11:00 in southern summer (January)","description":"Any scheduling code that assumes 'DST offset == standard offset +/- 60 minutes' and uses that to detect or compute DST transitions."},"what_goes_wrong":"Code that infers 'is this a DST transition' by checking whether the offset changed by exactly 60 minutes misses Lord Howe Island's transitions (and misclassifies the island's clock change as a data error).","why":"Australia/Lord_Howe is currently the only zone in the tz database whose DST shift is 30 minutes: standard time is UTC+10:30, and daylight time is UTC+11:00, a half-hour step instead of the usual hour. The IANA tz database encodes this per-zone; there is no general rule that DST is always 60 minutes, so hardcoding that assumption breaks for this zone even though every API used (zoneinfo, Intl) reports the real offset correctly. (DST shifts of other sizes exist too: Antarctica/Troll moves two hours.)","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ntz = ZoneInfo(\"Australia/Lord_Howe\")\nsummer = datetime(2024, 1, 15, 12, 0, tzinfo=tz) # DST active (southern summer)\nwinter = datetime(2024, 7, 15, 12, 0, tzinfo=tz) # standard time\nprint(\"DST offset:\", summer.utcoffset(), \"dst() component:\", summer.dst())\nprint(\"standard offset:\", winter.utcoffset(), \"dst() component:\", winter.dst())\nprint(\"shift between the two, in minutes:\", (summer.utcoffset() - winter.utcoffset()).total_seconds() / 60)\n","expected_output":"DST offset: 11:00:00 dst() component: 0:30:00\nstandard offset: 10:30:00 dst() component: 0:00:00\nshift between the two, in minutes: 30.0","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function offsetMinutes(utcMs, timeZone) {\n const dtf = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false,\n year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });\n const parts = dtf.formatToParts(new Date(utcMs));\n const o = {};\n for (const p of parts) o[p.type] = p.value;\n const asUTC = Date.UTC(+o.year, +o.month - 1, +o.day, +o.hour, +o.minute, +o.second);\n return (asUTC - utcMs) / 60000;\n}\nconst summer = offsetMinutes(Date.UTC(2024, 0, 15, 12, 0, 0), 'Australia/Lord_Howe');\nconst winter = offsetMinutes(Date.UTC(2024, 6, 15, 12, 0, 0), 'Australia/Lord_Howe');\nconsole.log('DST offset (min):', summer, '| standard offset (min):', winter, '| shift (min):', summer - winter);\n","expected_output":"DST offset (min): 660 | standard offset (min): 630 | shift (min): 30","verified_with":"Node.js v22.22.2"}],"fix":"Never hardcode the size of a DST shift. Ask the timezone library for the actual UTC offset at each instant and diff those; don't assume +/-60 minutes anywhere in validation or transition-detection logic.","related":["fractional-utc-offsets-kathmandu-kolkata","southern-hemisphere-dst-reversed"],"sources":[{"title":"IANA Time Zone Database","url":"https://www.iana.org/time-zones"}],"tags":["timezone","scheduling","date parsing"],"test_cases":[]},{"id":"midnight-that-doesnt-exist-sao-paulo","title":"Midnight on November 4, 2018 never happened in Sao Paulo","category":"datetime","assumption":"Midnight -- 00:00:00 local time -- always exists, so 'start of day' is always a safe local timestamp to construct.","input":{"display":"America/Sao_Paulo: local clock jumps from 23:59:59 on Nov 3, 2018 (-03:00) directly to 01:00:00 on Nov 4, 2018 (-02:00) -- the entire hour from midnight to 1 AM is skipped","description":"'Start of day' timestamps (billing cutoffs, daily report boundaries, cache-expiry-at-midnight logic) for Brazil's historical DST transitions, which took effect at local midnight rather than at the 2 AM US developers are used to."},"what_goes_wrong":"Code that constructs 'local midnight' as the boundary between two days builds a wall-clock time that never existed in Sao Paulo on the day its 2018 DST started, and zoneinfo resolves it with the pre-transition offset rather than flagging anything.","why":"US and EU clock changes happen in the small hours, away from midnight, but Brazil changed its clocks at local midnight (00:00) for as long as it observed DST, which ended when Brazil abolished it in 2019 (the last DST period ended in February 2019). On 2018-11-04, America/Sao_Paulo's clock went straight from 23:59:59 (-03:00, standard time) on November 3rd to 01:00:00 (-02:00, daylight time) on November 4th -- so every local wall-clock time from 00:00 to 00:59 on the 4th does not exist, exactly like the America/New_York spring-forward gap, just at midnight instead of 2 AM. zoneinfo constructs the nonexistent 00:30 anyway, silently assigning it the pre-transition offset (-03:00). For 00:00 itself that happens to land on 03:00 UTC, which is the day's real first instant, 01:00 -02:00; but the object still claims to be 00:00, so code that formats it, or that assumes the day began 24 hours before the next midnight, is wrong (that day had 23 hours).","affects":["python"],"repros":[{"language":"python","status":"verified","code":"from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ntz = ZoneInfo(\"America/Sao_Paulo\")\nnonexistent_midnight = datetime(2018, 11, 4, 0, 30, tzinfo=tz)\nprint(nonexistent_midnight, nonexistent_midnight.utcoffset())\nprint(\"as UTC:\", nonexistent_midnight.astimezone(ZoneInfo(\"UTC\")))\n\n# compare to the clock on either side of the gap\nbefore = datetime(2018, 11, 3, 23, 30, tzinfo=tz)\nafter = datetime(2018, 11, 4, 1, 30, tzinfo=tz)\nprint(\"23:30 Nov 3 offset:\", before.utcoffset(), \"| 01:30 Nov 4 offset:\", after.utcoffset())\n","expected_output":"2018-11-04 00:30:00-03:00 -1 day, 21:00:00\nas UTC: 2018-11-04 03:30:00+00:00\n23:30 Nov 3 offset: -1 day, 21:00:00 | 01:30 Nov 4 offset: -1 day, 22:00:00","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Sao_Paulo', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });\nfor (const h of [2, 3]) { // UTC hours straddling the local midnight jump\n const t = Date.UTC(2018, 10, 4, h, 0, 0);\n console.log(h, 'UTC ->', fmt.format(new Date(t)));\n}\n","expected_output":"2 UTC -> 11/03/2018, 23:00\n3 UTC -> 11/04/2018, 01:00","verified_with":"Node.js v22.22.2"}],"fix":"Never assume 'local midnight' exists as a timestamp without checking the zone's transition rules for that specific date; compute day boundaries from a UTC instant and a zone-aware library rather than hand-constructing 00:00:00 local time.","related":["dst-spring-forward-nonexistent-time"],"sources":[{"title":"IANA Time Zone Database","url":"https://www.iana.org/time-zones"},{"title":"Python docs: zoneinfo","url":"https://docs.python.org/3/library/zoneinfo.html"},{"title":"IANA tz database source, southamerica file (Brazil rules)","url":"https://github.com/eggert/tz/blob/main/southamerica"}],"tags":["timezone","scheduling","date parsing"],"test_cases":[]},{"id":"python-naive-aware-comparison-typeerror","title":"Comparing a timezone-aware datetime to a naive one doesn't give the wrong answer -- it refuses to answer at all","category":"datetime","assumption":"Two datetime objects can always be compared with < or == as long as they represent real moments in time.","input":{"display":"datetime(2024, 1, 1) < datetime(2024, 1, 1, tzinfo=timezone.utc)","description":"Mixing a datetime parsed from a source that attaches a timezone (e.g. an API response with an offset) with one created locally via datetime.now() or a form field with no timezone info, then comparing or sorting them together."},"what_goes_wrong":"Sorting or comparing a list of datetimes where some came from a timezone-aware source and others didn't raises `TypeError` at the comparison, not at creation, often deep inside a sort() call far from where the mismatched datetime was built.","why":"Python deliberately refuses to compare a 'naive' datetime (no attached tzinfo, interpretation of its clock reading is unstated) with an 'aware' one (tzinfo attached, unambiguous UTC instant) via the ordering operators (<, <=, >, >=), because there is no safe default assumption to make about what timezone the naive one is in. This is a documented decision, not a bug: Python could guess the naive value is UTC or local time, but either guess could silently be wrong, so it raises instead. Equality is the exception: since Python 3.3, == between a naive and an aware datetime returns False instead of raising, so a mixed pair silently never matches (the repro's last line).","affects":["python"],"repros":[{"language":"python","status":"verified","code":"from datetime import datetime, timezone\n\nnaive = datetime(2024, 1, 1)\naware = datetime(2024, 1, 1, tzinfo=timezone.utc)\ntry:\n naive < aware\nexcept TypeError as e:\n print(\"TypeError:\", e)\n\n# Equality does NOT raise -- it just always says they're different, which is its own trap.\nprint(\"naive == aware (no error, but is this right?):\", naive == aware)\n","expected_output":"TypeError: can't compare offset-naive and offset-aware datetimes\nnaive == aware (no error, but is this right?): False","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// JS Date has no naive/aware distinction: every Date is always a UTC instant,\n// so no equivalent comparison error can occur; the comparison always succeeds.\nconst a = new Date(Date.UTC(2024, 0, 1));\nconst b = new Date(Date.UTC(2024, 0, 1));\nconsole.log('a < b:', a < b, '| a.getTime() === b.getTime():', a.getTime() === b.getTime());\n","expected_output":"a < b: false | a.getTime() === b.getTime(): true","verified_with":"Node.js v22.22.2"}],"fix":"Pick one representation (aware, ideally UTC) at every boundary where a datetime enters your system, and convert immediately; never let a naive and an aware datetime coexist in the same comparison, sort, or collection.","related":["datetime-utcnow-naive-deprecated"],"sources":[{"title":"Python docs: datetime -- Aware and Naive Objects","url":"https://docs.python.org/3/library/datetime.html#aware-and-naive-objects"}],"tags":["timezone","sorting","comparison","database"],"test_cases":[]},{"id":"samoa-skipped-day-2011","title":"December 30, 2011 does not exist on the calendar in Samoa","category":"datetime","assumption":"Every calendar date between two dates that both exist must itself exist.","input":{"display":"Pacific/Apia: local calendar goes from 2011-12-29 directly to 2011-12-31 -- Dec 30, 2011 was skipped entirely","description":"Date-range iteration, 'days since' calculations, or calendar/report generation for Samoa and a few other Pacific nations that moved across the International Date Line."},"what_goes_wrong":"Stepping through local days by adding 24 hours to an instant jumps straight from Dec 29 to Dec 31, 2011 in Samoa, while code that iterates naive calendar dates produces a Dec 30 row whose local times never happened -- and constructing one succeeds silently with the old -10:00 offset.","why":"Samoa moved from the east side of the International Date Line to the west side to align with its main trading partners (Australia and New Zealand), skipping Friday, December 30, 2011 entirely: the clock went from 23:59:59 on Thursday the 29th straight to 00:00:00 on Saturday the 31st. This was a one-time political decision recorded in the IANA tz database, not a recurring rule. zoneinfo does not refuse to construct a datetime for the skipped day -- like the DST spring-forward gap, it silently resolves it using the offset in effect just before the jump (UTC-10) rather than raising an error.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\ntz = ZoneInfo(\"Pacific/Apia\")\nbefore = datetime(2011, 12, 29, 12, 0, tzinfo=tz)\nafter = datetime(2011, 12, 31, 12, 0, tzinfo=tz)\nskipped = datetime(2011, 12, 30, 12, 0, tzinfo=tz) # the day that never happened\nprint(\"before:\", before, before.utcoffset())\nprint(\"after:\", after, after.utcoffset())\nprint(\"'skipped day' constructed anyway:\", skipped, skipped.utcoffset())\nprint(\"before -> after, real hours apart:\", (after.astimezone(ZoneInfo('UTC')) - before.astimezone(ZoneInfo('UTC'))).total_seconds() / 3600)\n","expected_output":"before: 2011-12-29 12:00:00-10:00 -1 day, 14:00:00\nafter: 2011-12-31 12:00:00+14:00 14:00:00\n'skipped day' constructed anyway: 2011-12-30 12:00:00-10:00 -1 day, 14:00:00\nbefore -> after, real hours apart: 24.0","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'Pacific/Apia', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });\n// Walk hour by hour through the UTC window and watch the local calendar date jump.\nfor (let h = 8; h <= 11; h++) {\n const t = Date.UTC(2011, 11, 30, h, 0, 0);\n console.log(h, 'UTC on Dec 30 ->', fmt.format(new Date(t)));\n}\n","expected_output":"8 UTC on Dec 30 -> 12/29/2011, 22:00\n9 UTC on Dec 30 -> 12/29/2011, 23:00\n10 UTC on Dec 30 -> 12/31/2011, 00:00\n11 UTC on Dec 30 -> 12/31/2011, 01:00","verified_with":"Node.js v22.22.2"}],"fix":"Never assume calendar dates are contiguous across arbitrary ranges for arbitrary zones; let the tz-aware library enumerate real local dates (or do the iteration in UTC and convert each instant to local for display) rather than looping over a naive date range.","related":["dst-spring-forward-nonexistent-time"],"sources":[{"title":"IANA Time Zone Database","url":"https://www.iana.org/time-zones"},{"title":"Python docs: zoneinfo","url":"https://docs.python.org/3/library/zoneinfo.html"}],"tags":["timezone","date parsing","scheduling"],"test_cases":[]},{"id":"southern-hemisphere-dst-reversed","title":"Sydney is on daylight saving time in January and off it in July -- the opposite of New York","category":"datetime","assumption":"Daylight saving time is 'on' roughly March through November and 'off' in the winter months.","input":{"display":"Australia/Sydney: UTC+11 (DST) in January, UTC+10 (standard) in July; America/New_York: UTC-4 (DST) in July, UTC-5 (standard) in January","description":"A 'business hours' or 'is it currently DST' heuristic that hardcodes a month range, applied to users in the Southern Hemisphere."},"what_goes_wrong":"A heuristic like 'DST is active for timestamps between March and November' (true for most of the Northern Hemisphere) silently inverts for Australian, South American, and other Southern Hemisphere users, who are in DST during the Northern Hemisphere's winter.","why":"Daylight saving time is tied to each hemisphere's local summer, when the extra evening daylight is wanted, so the Southern Hemisphere's DST season (roughly October to April) runs opposite to the Northern Hemisphere's (roughly March to November). There is no universal 'DST months' rule in the tz database; each zone's transition rule is independent and the only correct way to know whether DST is active at a given instant is to ask the zone, not a hardcoded month range.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from zoneinfo import ZoneInfo\nfrom datetime import datetime\n\nsydney = ZoneInfo(\"Australia/Sydney\")\nny = ZoneInfo(\"America/New_York\")\njan_sydney = datetime(2024, 1, 15, 12, 0, tzinfo=sydney)\njul_sydney = datetime(2024, 7, 15, 12, 0, tzinfo=sydney)\njan_ny = datetime(2024, 1, 15, 12, 0, tzinfo=ny)\njul_ny = datetime(2024, 7, 15, 12, 0, tzinfo=ny)\nprint(\"Sydney Jan DST active:\", jan_sydney.dst() != __import__('datetime').timedelta(0), \"| Jul DST active:\", jul_sydney.dst() != __import__('datetime').timedelta(0))\nprint(\"New York Jan DST active:\", jan_ny.dst() != __import__('datetime').timedelta(0), \"| Jul DST active:\", jul_ny.dst() != __import__('datetime').timedelta(0))\n","expected_output":"Sydney Jan DST active: True | Jul DST active: False\nNew York Jan DST active: False | Jul DST active: True","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function offsetMinutes(utcMs, timeZone) {\n const dtf = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false,\n year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });\n const parts = dtf.formatToParts(new Date(utcMs));\n const o = {};\n for (const p of parts) o[p.type] = p.value;\n const asUTC = Date.UTC(+o.year, +o.month - 1, +o.day, +o.hour, +o.minute, +o.second);\n return (asUTC - utcMs) / 60000;\n}\nconst jan = Date.UTC(2024, 0, 15, 12, 0, 0);\nconst jul = Date.UTC(2024, 6, 15, 12, 0, 0);\nconsole.log('Sydney Jan offset (min):', offsetMinutes(jan, 'Australia/Sydney'), '| Jul offset (min):', offsetMinutes(jul, 'Australia/Sydney'));\nconsole.log('New York Jan offset (min):', offsetMinutes(jan, 'America/New_York'), '| Jul offset (min):', offsetMinutes(jul, 'America/New_York'));\n","expected_output":"Sydney Jan offset (min): 660 | Jul offset (min): 600\nNew York Jan offset (min): -300 | Jul offset (min): -240","verified_with":"Node.js v22.22.2"}],"fix":"Never infer whether DST is active from the calendar month. Ask the timezone library for the UTC offset (or, in Python, the `.dst()` component) at the specific instant and zone you care about.","related":["lord-howe-30-minute-dst"],"sources":[{"title":"IANA Time Zone Database","url":"https://www.iana.org/time-zones"},{"title":"Python docs: datetime.dst","url":"https://docs.python.org/3/library/datetime.html#datetime.datetime.dst"}],"tags":["timezone","scheduling","date parsing"],"test_cases":[]},{"id":"two-digit-year-century-pivot-mismatch","title":"The same two-digit year string lands in a different century depending on which language parses it","category":"datetime","assumption":"A two-digit year like '68' means the same calendar year no matter which system parses the date string it's embedded in.","input":{"display":"\"01/01/68\" parsed by Python's strptime(\"%m/%d/%y\") vs JavaScript's legacy Date(string) parser","description":"A date string with a 2-digit year (common in older CSV exports, legacy forms, and some locale-formatted dates) passed between a Python backend and a JavaScript frontend."},"what_goes_wrong":"The same string \"01/01/68\" is read as 2068 by Python and 1968 by Node.js, so a date passed between the two silently moves a century; years 00-49 and 69-99 agree, hiding the bug.","why":"Python's strptime with %y follows the POSIX convention: two-digit years 00-68 become 2000-2068 and 69-99 become 1969-1999. V8's legacy (non-ISO) Date parser, used by Node.js and Chrome, pivots at 50 instead: 00-49 become 2000-2049 and 50-99 become 1950-1999. So for the nineteen years 50 through 68 the two runtimes disagree by exactly a century, while every other two-digit year agrees, which is why the bug survives casual testing. ECMA-262 leaves parsing of non-ISO date strings implementation-defined, so other JavaScript engines are free to choose differently again.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"from datetime import datetime\n\nfor s in (\"01/01/49\", \"01/01/50\", \"01/01/68\", \"01/01/69\"):\n print(s, \"->\", datetime.strptime(s, \"%m/%d/%y\").year)\n","expected_output":"01/01/49 -> 2049\n01/01/50 -> 2050\n01/01/68 -> 2068\n01/01/69 -> 1969","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"for (const s of ['01/01/49', '01/01/50', '01/01/68', '01/01/69']) {\n console.log(s, '->', new Date(s).getFullYear());\n}\n","expected_output":"01/01/49 -> 2049\n01/01/50 -> 1950\n01/01/68 -> 1968\n01/01/69 -> 1969","verified_with":"Node.js v22.22.2"}],"fix":"Never rely on implicit two-digit-year parsing across a language boundary. Require 4-digit years at every system edge, or, if you must accept 2-digit years, write and apply your own explicit pivot rule identically on both sides.","related":["century-leap-year-1900-not-leap"],"sources":[{"title":"Python docs: strftime/strptime format codes (%y)","url":"https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes"},{"title":"ECMA-262: Date.parse (non-ISO formats are implementation-defined)","url":"https://tc39.es/ecma262/#sec-date.parse"}],"tags":["date parsing","validation"],"test_cases":[]},{"id":"year-2038-int32-overflow","title":"The Unix clock runs out of room in a signed 32-bit integer on January 19, 2038","category":"datetime","assumption":"A timestamp stored as 'seconds since the Unix epoch' can represent any date you throw at it.","input":{"display":"2**31 - 1 seconds since 1970-01-01T00:00:00Z == 2038-01-19T03:14:07Z; one more second overflows a signed 32-bit integer","description":"Any system storing Unix time in a 32-bit signed integer column or struct field: older database schemas, C structs, embedded firmware, some binary file formats."},"what_goes_wrong":"A timestamp stored as a signed 32-bit seconds-since-epoch field wraps to a large negative number (around 1901) the instant it passes 2038-01-19T03:14:07Z, making a future date look like it's over a century in the past.","why":"2**31 - 1 (2,147,483,647) is the largest value a signed 32-bit integer can hold, and it corresponds to exactly 2038-01-19T03:14:07 UTC when interpreted as seconds since the Unix epoch. The next second would need to store 2**31, which a signed 32-bit integer cannot represent; it wraps around to -2**31, which as a Unix timestamp is in December 1901. Modern Python and JavaScript date objects aren't limited this way (Python's int is arbitrary precision and JS Date uses a 64-bit float of milliseconds), but anything that stores or transmits time as a literal signed 32-bit integer -- which includes plenty of still-running C code, old filesystems, and database columns -- is exposed exactly like a 2-digit-year Y2K bug, just 38 years later.","affects":["python","javascript","c","any 32-bit signed time_t"],"repros":[{"language":"python","status":"verified","code":"from datetime import datetime, timezone, timedelta\n\nmax_int32_seconds = 2**31 - 1\nboundary = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=max_int32_seconds)\nprint(\"last representable instant in signed 32-bit Unix time:\", boundary)\n\n# Simulate the wraparound a 32-bit signed integer would actually do.\ndef wrap_int32(n):\n n &= 0xFFFFFFFF\n return n - 0x100000000 if n >= 0x80000000 else n\n\nprint(\"next second, wrapped to signed 32-bit:\", wrap_int32(max_int32_seconds + 1))\n","expected_output":"last representable instant in signed 32-bit Unix time: 2038-01-19 03:14:07+00:00\nnext second, wrapped to signed 32-bit: -2147483648","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const maxInt32Seconds = 2 ** 31 - 1;\nconsole.log('boundary instant:', new Date(maxInt32Seconds * 1000).toISOString());\n\nfunction wrapInt32(n) {\n return n | 0; // JS bitwise OR forces 32-bit signed truncation\n}\nconsole.log('next second, wrapped to signed 32-bit:', wrapInt32(maxInt32Seconds + 1));\n","expected_output":"boundary instant: 2038-01-19T03:14:07.000Z\nnext second, wrapped to signed 32-bit: -2147483648","verified_with":"Node.js v22.22.2"}],"fix":"Never store a timestamp in a signed 32-bit field for anything that must outlive 2038; use a 64-bit integer (or your language's native arbitrary-precision/64-bit date type) for any seconds-since-epoch storage, and audit older schemas and binary formats specifically for this.","related":["century-leap-year-1900-not-leap"],"sources":[{"title":"Wikipedia: Year 2038 problem (background, verify against POSIX time_t definition)","url":"https://en.wikipedia.org/wiki/Year_2038_problem"},{"title":"IEEE Std 1003.1 (POSIX): time_t","url":"https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_16"}],"tags":["timestamps","database","validation"],"test_cases":[]},{"id":"absolute-second-argument-resets-join","title":"os.path.join(\"/srv/uploads\", \"/etc/passwd\") returns \"/etc/passwd\" — the base directory just vanishes","category":"filenames","assumption":"Passing an absolute-looking string as the second argument to a join function is harmless, since the first argument is the 'real' base.","input":{"display":"os.path.join(\"/srv/uploads\", \"/etc/passwd\")","description":"A filename field a user can make start with \"/\" — a tar entry name, a form field never checked for a leading slash, or a URL path segment decoded before joining."},"what_goes_wrong":"If the user-controlled piece itself looks absolute, Python's join discards the trusted base entirely and returns the attacker-controlled absolute path unchanged — a complete, silent takeover of the result, with no `..` traversal syntax required at all.","why":"Per the Python docs, os.path.join treats any argument that looks absolute as resetting the path: every previously joined component is discarded and joining continues from that absolute component onward. Node's path.join does NOT implement this reset — it treats the leading slash of a later argument as just another separator and concatenates everything — so the identical call in Node keeps the base directory intact. path.resolve, however, DOES implement the Python-style reset, because resolve is documented to process its arguments right-to-left until it has built an absolute path, stopping the moment one argument is itself absolute.","affects":["python os.path.join (resets on an absolute argument)","node path.resolve (resets on an absolute argument)","node path.join (does NOT reset — differs from Python's join)"],"repros":[{"language":"python","status":"verified","code":"import os.path\nprint(os.path.join(\"/srv/uploads\", \"/etc/passwd\"))\n","expected_output":"/etc/passwd","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconsole.log(\"join:\", path.join(\"/srv/uploads\", \"/etc/passwd\"));\nconsole.log(\"resolve:\", path.resolve(\"/srv/uploads\", \"/etc/passwd\"));\n","expected_output":"join: /srv/uploads/etc/passwd\nresolve: /etc/passwd","verified_with":"Node.js v22.22.2"}],"fix":"Never pass a user-controlled string straight into join/resolve as if it were just a filename; reject any value that starts with a path separator (or a drive letter on Windows) before it ever reaches a join call.","related":["path-join-escapes-the-base-directory"],"sources":[{"title":"Python docs: os.path.join","url":"https://docs.python.org/3/library/os.path.html#os.path.join"},{"title":"Node.js docs: path.resolve","url":"https://nodejs.org/api/path.html#pathresolvepaths"}],"tags":["filename","path","security","validation","upload"],"test_cases":[{"inputs":["/srv/uploads","/etc/passwd"],"relation":"no_crash","note":"Check whether your join/resolve call discards the base directory on an absolute second argument — the two most common path libraries disagree with each other here."}]},{"id":"bidi-override-extension-spoof","title":"A filename can display with a totally different extension than the one splitext actually finds","category":"filenames","assumption":"The characters your eyes read, left to right, are the characters the code is operating on.","input":{"display":"\"invoice\" + U+202E (RIGHT-TO-LEFT OVERRIDE) + \"cod.exe\"","description":"A malicious email attachment or download name designed to visually read as a harmless document while actually being an executable."},"what_goes_wrong":"A person reviewing the filename in a file list or email client sees what renders as \"invoiceexe.doc\" (the override reverses everything after it) and approves it as a document, while the real file, in logical order, is \"invoice\" + override + \"cod.exe\" — a .exe file.","why":"U+202E is a Unicode bidirectional-formatting control character that tells a rendering engine to display everything after it in right-to-left visual order, with no change at all to the underlying code point sequence. splitext/extname operate on that unchanged logical sequence, so they correctly report the real extension as \".exe\"; it is only the ON-SCREEN rendering a human uses to judge the file that gets visually reversed into something else entirely.","affects":["any renderer following the unicode bidirectional algorithm (terminals, file managers, email clients)","python os.path.splitext (reports the true, unreversed extension)","node path.extname (reports the true, unreversed extension)"],"repros":[{"language":"python","status":"verified","code":"import os.path\nRLO = \"\\u202e\"\nname = \"invoice\" + RLO + \"cod.exe\"\nprint([hex(ord(c)) for c in name])\nprint(\"real extension:\", os.path.splitext(name)[1])\n","expected_output":"['0x69', '0x6e', '0x76', '0x6f', '0x69', '0x63', '0x65', '0x202e', '0x63', '0x6f', '0x64', '0x2e', '0x65', '0x78', '0x65']\nreal extension: .exe","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconst RLO = \"\\u202e\";\nconst name = \"invoice\" + RLO + \"cod.exe\";\nconsole.log([...name].map((c) => \"0x\" + c.codePointAt(0).toString(16)));\nconsole.log(\"real extension:\", path.extname(name));\n","expected_output":"[\n '0x69', '0x6e', '0x76',\n '0x6f', '0x69', '0x63',\n '0x65', '0x202e', '0x63',\n '0x6f', '0x64', '0x2e',\n '0x65', '0x78', '0x65'\n]\nreal extension: .exe","verified_with":"Node.js v22.22.2"}],"fix":"Reject or strip Unicode bidirectional control characters (U+202A-U+202E, U+2066-U+2069) from filenames before display or validation, and always trust the logical extension splitext/extname reports, never what a filename visually appears to end in.","related":["extension-trap-double-extension"],"sources":[{"title":"Unicode Technical Report #9: Unicode Bidirectional Algorithm","url":"https://www.unicode.org/reports/tr9/"}],"tags":["filename","path","security","validation","upload","unicode"],"test_cases":[{"inputs":["invoicecod.exe"],"relation":"no_crash","note":"Your extension check should see '.exe' here even though the filename could render on screen as something else entirely."}]},{"id":"case-insensitive-filesystem-pure-path-mismatch","title":"\"Report.txt\" and \"report.txt\" are the same file on a default Mac or Windows disk, but \"==\" says no","category":"filenames","assumption":"If two path strings compare unequal, they refer to different files.","input":{"display":"\"/Users/me/Report.txt\" vs \"/Users/me/report.txt\"","description":"A second upload, a cache key, or a git checkout where only the case of a filename differs from an existing one."},"what_goes_wrong":"A uniqueness check (a set of seen paths, a dict key, a plain == comparison) treats the two strings as different files, while the OS silently aliases the second \"create\" onto the first, or vice versa.","why":"macOS's default APFS volume format is case-insensitive but case-preserving: it keeps the case you typed but treats names differing only in case as the same entry (a case-sensitive APFS variant exists but is not the default). Windows treats names case-insensitively by default too, though NTFS directories can be flagged case-sensitive individually; Linux ext4 is case-sensitive. Pure path libraries never consult the disk: PurePosixPath and Node's path.posix compare the literal strings, so a case difference always compares unequal. (pathlib.PureWindowsPath, by contrast, compares case-insensitively, so pathlib's answer depends on which flavour you picked, not on the disk underneath.)","affects":["macos apfs (default variant: case-insensitive, case-preserving)","windows ntfs (case-insensitive by default; per-directory case sensitivity can be enabled)","linux ext4 (case-sensitive)","pathlib.PurePosixPath / node path.posix (literal string comparison)"],"repros":[{"language":"python","status":"verified","code":"from pathlib import PurePosixPath\na = PurePosixPath(\"/Users/me/Report.txt\")\nb = PurePosixPath(\"/Users/me/report.txt\")\nprint(a == b, str(a) == str(b))\n","expected_output":"False False","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconst a = path.posix.normalize(\"/Users/me/Report.txt\");\nconst b = path.posix.normalize(\"/Users/me/report.txt\");\nconsole.log(a === b);\n","expected_output":"false","verified_with":"Node.js v22.22.2"},{"language":"macos","status":"documented","note":"On a default (case-insensitive) APFS volume, opening /Users/me/report.txt for writing when /Users/me/Report.txt already exists opens the existing file; the directory keeps one entry, still spelled Report.txt.","source":"https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html"}],"fix":"Decide your own case policy at the application layer (usually: normalize to one case before using a path as a key) rather than trusting string equality or the filesystem's default behavior, since the two disagree and the filesystem's own behavior varies by volume format.","related":["windows-trailing-dot-space-stripped"],"sources":[{"title":"Apple File System Guide: FAQ (case sensitivity)","url":"https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html"},{"title":"Microsoft Learn: Naming Files, Paths, and Namespaces","url":"https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"},{"title":"Microsoft Learn: Case sensitivity (per-directory flag)","url":"https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity"}],"tags":["filename","path","uniqueness","validation","upload","database"],"test_cases":[{"inputs":["Report.txt","report.txt"],"relation":"equal","note":"A filename-key function meant to be safe on default macOS and Windows disks should map these to the same key, because those disks treat them as one file."}]},{"id":"dotfile-has-no-extension","title":"\".bashrc\" has no extension at all, by the exact same rule that found \".gz\" in \"archive.tar.gz\"","category":"filenames","assumption":"Any filename starting with a dot has an extension equal to everything after that dot.","input":{"display":"\".bashrc\"","description":"A Unix dotfile (.bashrc, .gitignore, .env) passed through the same extension-detection code used for ordinary uploads."},"what_goes_wrong":"A filter that rejects uploads without a 'recognized extension', or a viewer that picks a syntax highlighter by extension, treats a dotfile as having none, because by the splitext/extname rule it genuinely doesn't.","why":"splitext and extname both special-case a leading dot: they look for the LAST dot that is not the first character of the filename. That rule is exactly what correctly makes \"archive.tar.gz\" yield \".gz\" instead of treating the whole name as one giant extension, but the same rule means a name that starts with a dot and has no OTHER dot, like \".bashrc\", has nothing after any qualifying dot — Python returns (\".bashrc\", \"\") and Node returns the empty string.","affects":["python os.path.splitext","node path.extname"],"repros":[{"language":"python","status":"verified","code":"import os.path\nprint(os.path.splitext(\".bashrc\"))\n","expected_output":"('.bashrc', '')","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconsole.log(JSON.stringify(path.extname(\".bashrc\")));\n","expected_output":"\"\"","verified_with":"Node.js v22.22.2"}],"fix":"Don't assume every file needs a non-empty extension to be valid; treat a leading-dot, no-further-dot name as a legitimate zero-extension dotfile rather than a malformed upload.","related":["extension-trap-double-extension"],"sources":[{"title":"Python docs: os.path.splitext","url":"https://docs.python.org/3/library/os.path.html#os.path.splitext"},{"title":"Node.js docs: path.extname","url":"https://nodejs.org/api/path.html#pathextnamepath"}],"tags":["filename","path","parsing","validation","upload"],"test_cases":[{"inputs":[".bashrc"],"relation":"returns","expected":"","note":"Your extension-required upload filter needs to decide, deliberately, whether an extension-free dotfile is allowed."}]},{"id":"extension-trap-double-extension","title":"splitext(\"archive.tar.gz\") gives you \".gz\", not \".tar.gz\"","category":"filenames","assumption":"The 'extension' of a filename is everything after the first dot.","input":{"display":"\"archive.tar.gz\"","description":"Any compressed-archive filename, used to decide how to decompress an uploaded or downloaded file."},"what_goes_wrong":"Code that branches on the file's extension to pick a decompressor sees only \".gz\", runs gunzip, and is left holding a .tar file it never un-tars — or mis-identifies the file type entirely.","why":"Both os.path.splitext and Node's path.extname define 'the extension' as the text from the LAST dot to the end of the string, a deliberate choice so a filename like \"version.1.2.3\" isn't treated as having the extension \".1.2.3\". That same rule means a genuinely double-barreled extension like \".tar.gz\" is reported as just \".gz\"; the \".tar\" part is silently folded into what both functions report as the base name.","affects":["python os.path.splitext","node path.extname","any 'branch on extension' dispatcher"],"repros":[{"language":"python","status":"verified","code":"import os.path\nprint(os.path.splitext(\"archive.tar.gz\"))\n","expected_output":"('archive.tar', '.gz')","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconsole.log(path.extname(\"archive.tar.gz\"));\nconsole.log(path.basename(\"archive.tar.gz\", path.extname(\"archive.tar.gz\")));\n","expected_output":".gz\narchive.tar","verified_with":"Node.js v22.22.2"}],"fix":"For known compound suffixes (.tar.gz, .tar.bz2, .tar.xz, ...), check against an explicit list of multi-part suffixes before falling back to splitext/extname.","related":["dotfile-has-no-extension"],"sources":[{"title":"Python docs: os.path.splitext","url":"https://docs.python.org/3/library/os.path.html#os.path.splitext"},{"title":"Node.js docs: path.extname","url":"https://nodejs.org/api/path.html#pathextnamepath"}],"tags":["filename","path","parsing","upload","validation"],"test_cases":[{"inputs":["archive.tar.gz"],"relation":"returns","expected":".tar.gz","note":"An extension detector used to pick a decompressor must recognize compound archive suffixes; splitext/extname alone return only '.gz'."}]},{"id":"filename-length-limits-bytes-not-chars","title":"A 255-byte filename limit fits only 85 Chinese characters","category":"filenames","assumption":"A filename length limit measured in characters means you can always fit that many characters, in any script.","input":{"display":"85 repetitions of U+6587 (文) vs 86 repetitions","description":"A user-supplied title or display name being used, unmodified, as a filename or as a path component in an export."},"what_goes_wrong":"A filename that looks well under a '255 character' budget by character count can already be AT or OVER the real filesystem limit, because most Linux filesystems enforce the limit in BYTES of the encoded name, and common CJK characters take 3 bytes each in UTF-8.","why":"ext4 and most other Linux filesystems cap a single path component at 255 bytes, not 255 Unicode code points. A Latin-script name is about one byte per character, so the limit feels like a character limit, but a common CJK character encodes to 3 bytes in UTF-8, so 85 of them consume all 255 bytes and the 86th pushes the name past the limit at a character count of only 86. Windows measures differently again: an NTFS name component is limited to 255 UTF-16 code units, and the Win32 API separately caps the whole path at MAX_PATH (260 characters) unless long-path support is enabled, so a limit tuned for one platform does not carry over to the other.","affects":["ext4 / most linux filesystems (255 bytes per component)","windows (255 utf-16 units per ntfs component; 260-character MAX_PATH for the whole path unless long paths are enabled)"],"repros":[{"language":"python","status":"verified","code":"s85 = \"\\u6587\" * 85\ns86 = \"\\u6587\" * 86\nprint(len(s85), len(s85.encode(\"utf-8\")))\nprint(len(s86), len(s86.encode(\"utf-8\")))\nprint(len(s86.encode(\"utf-8\")) > 255)\n","expected_output":"85 255\n86 258\nTrue","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const s85 = \"\\u6587\".repeat(85);\nconst s86 = \"\\u6587\".repeat(86);\nconsole.log(s85.length, Buffer.byteLength(s85, \"utf8\"));\nconsole.log(s86.length, Buffer.byteLength(s86, \"utf8\"));\nconsole.log(Buffer.byteLength(s86, \"utf8\") > 255);\n","expected_output":"85 255\n86 258\ntrue","verified_with":"Node.js v22.22.2"}],"fix":"When checking a filename against a filesystem's length limit, measure the encoded byte length (UTF-8 for Linux) against that filesystem's actual limit, not the character or code-point count, and look up the right number for the right platform instead of reusing '255' everywhere.","related":[],"sources":[{"title":"Linux kernel docs: ext4 directory entries (255-byte names)","url":"https://www.kernel.org/doc/html/latest/filesystems/ext4/directory.html"},{"title":"Microsoft Learn: Maximum Path Length Limitation","url":"https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation"}],"tags":["filename","path","length validation","upload","i18n","validation"],"test_cases":[{"inputs":["文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文"],"relation":"raises","note":"86 CJK characters is 258 UTF-8 bytes: a validator enforcing a 255-byte filename limit should reject this even though it is only 86 characters."},{"inputs":["文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文"],"relation":"no_crash","note":"85 of them is exactly 255 bytes and should be accepted."}]},{"id":"leading-dash-filename-parsed-as-flag","title":"A file literally named \"-rf\" is not a filename to most command-line tools; it is two flags","category":"filenames","assumption":"A string you pass as a filename argument is always interpreted as a filename.","input":{"display":"\"-rf\"","description":"A filename generated from user content (a title, a hashtag, a ticket id) that happens to start with a hyphen, then passed as a bare positional argument to a CLI tool or argument parser."},"what_goes_wrong":"A command built as `tool <filename>` with a filename of \"-rf\" is read by the tool's argument parser as options, not a filename. At best the tool errors out; in the classic case, `rm *` in a directory containing a file named \"-rf\" hands that name to rm as an argument, and GNU rm (which accepts options anywhere on the command line) reads it as its own -r and -f flags, so rm deletes the other matched directories recursively and without prompting.","why":"POSIX-style argument parsers decide whether a token is an option by its first character: a leading '-' (other than a lone \"-\", which conventionally means standard input) marks it as a flag, whatever the program meant that argument to be. Python's argparse applies this rule and refuses \"-rf\" as a positional, exiting with a usage error. Node's util.parseArgs, in its non-strict mode, silently parses it as the two boolean flags r and f and leaves no positional at all; in strict mode it throws on the unknown option. Both accept the same token as a filename once it follows a \"--\" end-of-options marker.","affects":["posix-style cli argument parsing (not limited to one language)","python argparse (rejects a leading-dash positional)","node util.parseArgs (non-strict: parses it as flags; strict: throws)"],"repros":[{"language":"python","status":"verified","code":"import argparse\nparser = argparse.ArgumentParser()\nparser.add_argument(\"filename\")\ntry:\n args = parser.parse_args([\"-rf\"])\n print(\"parsed filename:\", args.filename)\nexcept SystemExit as e:\n print(\"argparse rejected it as a flag, exit code\", e.code)\n","expected_output":"argparse rejected it as a flag, exit code 2","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import { parseArgs } from \"node:util\";\nconst loose = parseArgs({ args: [\"-rf\"], allowPositionals: true, strict: false });\nconsole.log(\"non-strict:\", JSON.stringify(loose));\ntry {\n parseArgs({ args: [\"-rf\"], allowPositionals: true });\n} catch (e) {\n console.log(\"strict:\", e.code);\n}\nconst escaped = parseArgs({ args: [\"--\", \"-rf\"], allowPositionals: true });\nconsole.log(\"after --:\", JSON.stringify(escaped.positionals));\n","expected_output":"non-strict: {\"values\":{\"r\":true,\"f\":true},\"positionals\":[]}\nstrict: ERR_PARSE_ARGS_UNKNOWN_OPTION\nafter --: [\"-rf\"]","verified_with":"Node.js v22.22.2"}],"fix":"When a value could plausibly start with '-', pass it after a literal `--` end-of-options marker wherever the tool supports one, or prefix it with `./` so it no longer starts with a dash.","related":[],"sources":[{"title":"POSIX: Utility Syntax Guidelines (Guideline 10, the -- delimiter)","url":"https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap12.html"},{"title":"GNU C Library: Program Argument Syntax Conventions","url":"https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html"},{"title":"Python docs: argparse","url":"https://docs.python.org/3/library/argparse.html"},{"title":"Node.js docs: util.parseArgs","url":"https://nodejs.org/api/util.html#utilparseargsconfig"}],"tags":["filename","security","validation","upload","parsing"],"test_cases":[{"inputs":["-rf"],"relation":"no_crash","note":"A generated filename that starts with '-' needs an explicit escape before it reaches a CLI argument list, or it won't be read as a filename at all."}]},{"id":"nfc-nfd-filename-lookup-mismatch","title":"Two filenames that look identical can be different byte strings, and a Linux server will store both","category":"filenames","assumption":"A filename that looks the same on screen is the same filename on disk.","input":{"display":"\"caf\\u00e9.txt\" encoded as NFC (\\u00e9) vs the same visible name encoded as NFD (e + \\u0301)","description":"A filename that arrives in decomposed form (for example, a file that originated on an older Mac HFS+ volume, which stored names in a variant of NFD, or a client that decomposes text) stored verbatim on a Linux server, then looked up by the composed form a different client produced."},"what_goes_wrong":"A byte-exact filename search for the composed form finds nothing, even though a file with the 'same' visible name exists on disk, because the stored bytes are the decomposed form.","why":"NFC and NFD are both valid Unicode encodings of identical visible text, but they are different code-point sequences and therefore different UTF-8 bytes. Linux filesystems like ext4 treat names as opaque bytes with no normalization, so the two forms are two unrelated names: a lookup by one misses the other, and both can coexist in one directory. Mac filesystems behave differently: HFS+ stored names in a variant of NFD, while APFS preserves whichever form it was given but matches names normalization-insensitively, so the same lookup that succeeds on a Mac fails once the file is on a Linux server.","affects":["ext4 / most linux filesystems (byte-wise names, no normalization)","macos hfs+ (stores a variant of nfd)","macos apfs (preserves the given form, normalization-insensitive lookup)","any client/server pair that disagrees on normalization form"],"repros":[{"language":"python","status":"verified","code":"import unicodedata\nnfc = unicodedata.normalize(\"NFC\", \"caf\\u00e9.txt\")\nnfd = unicodedata.normalize(\"NFD\", \"caf\\u00e9.txt\")\nprint(nfc == nfd, len(nfc), len(nfd))\nprint(nfc.encode(\"utf-8\") == nfd.encode(\"utf-8\"), len(nfc.encode(\"utf-8\")), len(nfd.encode(\"utf-8\")))\n","expected_output":"False 8 9\nFalse 9 10","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const nfc = \"caf\\u00e9.txt\".normalize(\"NFC\");\nconst nfd = \"caf\\u00e9.txt\".normalize(\"NFD\");\nconsole.log(nfc === nfd, nfc.length, nfd.length);\nconsole.log(Buffer.from(nfc, \"utf8\").equals(Buffer.from(nfd, \"utf8\")), Buffer.byteLength(nfc), Buffer.byteLength(nfd));\n","expected_output":"false 8 9\nfalse 9 10","verified_with":"Node.js v22.22.2"}],"fix":"Normalize every filename to one form (NFC is the usual choice) at the point it enters your system, before it ever reaches a save or a lookup, so client and server always agree on the byte sequence.","related":["nfc-nfd-same-name-two-accounts"],"sources":[{"title":"Unicode Standard Annex #15: Unicode Normalization Forms","url":"https://www.unicode.org/reports/tr15/"},{"title":"Apple Technical Q&A QA1173: Text Encodings in VFS (HFS+ decomposed names)","url":"https://developer.apple.com/library/archive/qa/qa1173/_index.html"},{"title":"Apple File System Guide: FAQ (APFS normalization and case sensitivity)","url":"https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html"}],"tags":["filename","path","normalization","upload","i18n","unicode"],"test_cases":[{"inputs":["café.txt","café.txt"],"relation":"equal","note":"A filename-key function should normalize (usually to NFC) so the composed and decomposed spellings map to the same key."}]},{"id":"ntfs-alternate-data-stream-colon","title":"\"file.txt:hidden\" isn't a second file on NTFS — it's extra data bolted onto file.txt that `dir` will never show you","category":"filenames","assumption":"A colon in a filename is just another character, and a name containing one refers to a distinct file.","input":{"display":"\"file.txt:hidden\"","description":"A filename accepted from user input (or produced by careless string concatenation) that is later written to an actual NTFS volume."},"what_goes_wrong":"On NTFS, writing to \"file.txt:hidden\" doesn't create a new file — it creates (or overwrites) an alternate data stream attached to the EXISTING file.txt, invisible to a normal directory listing and not counted in the visible file's reported size, so arbitrary extra data can be smuggled in behind an ordinary-looking file.","why":"NTFS filenames support multiple named 'data streams' per file entry, addressed as \"name:stream\"; the unnamed stream is the file's normal content, and \"file.txt:hidden\" addresses an additional stream on the SAME directory entry as file.txt, not a sibling file. Pure path libraries have no awareness of this NTFS-specific syntax at all — they just see a colon character — so splitext and basename don't special-case it; splitext even folds the whole \":hidden\" suffix into what it reports as the file's 'extension'.","affects":["windows ntfs (alternate data streams)","python ntpath (no ads awareness — colon is just a character)","node path.win32 (no ads awareness)"],"repros":[{"language":"python","status":"verified","code":"import ntpath\nname = \"file.txt:hidden\"\nprint(\"splitdrive:\", ntpath.splitdrive(name))\nprint(\"splitext:\", ntpath.splitext(name))\nprint(\"basename:\", ntpath.basename(name))\n","expected_output":"splitdrive: ('', 'file.txt:hidden')\nsplitext: ('file', '.txt:hidden')\nbasename: file.txt:hidden","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconst name = \"file.txt:hidden\";\nconst parsed = path.win32.parse(name);\nconsole.log(\"ext:\", parsed.ext, \"name:\", parsed.name, \"base:\", parsed.base);\n","expected_output":"ext: .txt:hidden name: file base: file.txt:hidden","verified_with":"Node.js v22.22.2"}],"fix":"Reject colons in filenames outright before they reach a Windows filesystem unless you specifically intend to address a named stream, and never rely on directory listings or visible file size alone to audit what's actually stored in a file.","related":[],"sources":[{"title":"Microsoft Sysinternals: Streams (NTFS Alternate Data Streams)","url":"https://learn.microsoft.com/en-us/sysinternals/downloads/streams"},{"title":"Microsoft Learn: Naming Files, Paths, and Namespaces","url":"https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"}],"tags":["filename","path","security","validation","upload"],"test_cases":[{"inputs":["file.txt:hidden"],"relation":"no_crash","note":"Your filename validator should reject or specifically handle a colon before it reaches a Windows filesystem call."}]},{"id":"ntpath-splitdrive-unc-paths","title":"A UNC path's \"drive\" isn't a drive letter at all — it's the whole \\\\server\\share","category":"filenames","assumption":"splitdrive on a Windows path always returns either nothing or a single letter plus a colon.","input":{"display":"\"\\\\\\\\server\\\\share\\\\file.txt\" (UNC path) vs \"C:\\\\Users\\\\me\\\\file.txt\"","description":"A network path (a mapped file server, a backup target) handled by code that was only ever tested against local drive-letter paths."},"what_goes_wrong":"Code that assumes the 'drive' component is either empty or exactly two characters (\"C:\") mishandles a UNC path, truncating or misinterpreting the server/share portion that ntpath actually bundles into the drive.","why":"ntpath.splitdrive special-cases UNC syntax: for a path starting with two separators followed by a server and share name, it returns the ENTIRE \\\\server\\share prefix as the 'drive', not just a letter and colon, because that whole prefix has to stay together to resolve the rest of the path. Node's path.win32.parse does the analogous thing, putting the full \\\\server\\share\\ into `root`. A function written only against \"C:\" paths, and assuming a fixed two-character drive length, breaks silently on the far longer UNC form.","affects":["python ntpath.splitdrive","node path.win32 (root / parse)","windows unc paths"],"repros":[{"language":"python","status":"verified","code":"import ntpath\nprint(ntpath.splitdrive(\"C:\\\\Users\\\\me\\\\file.txt\"))\nprint(ntpath.splitdrive(\"\\\\\\\\server\\\\share\\\\file.txt\"))\n","expected_output":"('C:', '\\\\Users\\\\me\\\\file.txt')\n('\\\\\\\\server\\\\share', '\\\\file.txt')","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconsole.log(path.win32.parse(\"C:\\\\Users\\\\me\\\\file.txt\").root);\nconsole.log(path.win32.parse(\"\\\\\\\\server\\\\share\\\\file.txt\").root);\n","expected_output":"C:\\\n\\\\server\\share\\","verified_with":"Node.js v22.22.2"}],"fix":"Use the library's own splitdrive/parse output rather than assuming a fixed-width drive prefix, and test path-handling code against UNC paths explicitly, not just local drive letters.","related":[],"sources":[{"title":"Microsoft Learn: Naming Files, Paths, and Namespaces (UNC paths)","url":"https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"},{"title":"Python docs: os.path (ntpath)","url":"https://docs.python.org/3/library/os.path.html"}],"tags":["filename","path","parsing","validation"],"test_cases":[{"inputs":["\\\\server\\share\\file.txt"],"relation":"no_crash","note":"If your code assumes a drive component is always empty or exactly 'C:', test it against a UNC path like this one."}]},{"id":"nul-byte-in-filename-raises-at-open","title":"A filename with an embedded NUL byte sails through every path function, then blows up the moment you open it","category":"filenames","assumption":"If os.path.join / path.join didn't raise an error, the filename is safe to use.","input":{"display":"\"a\\u0000b\" (the letter a, a NUL byte, the letter b)","description":"A filename decoded from untrusted bytes (a multipart upload, a binary protocol field) that was never validated as a 'reasonable' string before being treated as a filename."},"what_goes_wrong":"String-only path functions accept the NUL byte without complaint and happily build a joined path containing it; the error only appears later, at the actual filesystem call, in a different part of the codebase than where the bad input was first accepted.","why":"os.path.join and path.join are pure string operations — they concatenate and, in Node's case, normalize separators, but neither inspects the byte content for anything the OS might reject. The OS filesystem call layer represents paths as NUL-terminated C strings, so an embedded NUL byte would truncate or corrupt the path at the syscall level; both Python's open() and Node's fs functions guard against this themselves and raise before making the call, rather than letting the OS ever see a truncated string.","affects":["python os.path.join (no validation)","node path.join (no validation)","python open() (raises ValueError)","node fs (raises ERR_INVALID_ARG_VALUE)"],"repros":[{"language":"python","status":"verified","code":"import os.path\njoined = os.path.join(\"a\\x00b\", \"c\")\nprint(\"join accepted it:\", repr(joined))\ntry:\n open(\"a\\x00b\")\nexcept ValueError as e:\n print(\"open() raised:\", e)\n","expected_output":"join accepted it: 'a\\x00b/c'\nopen() raised: embedded null byte","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nimport fs from 'node:fs';\nconst joined = path.join(\"a\\0b\", \"c\");\nconsole.log(\"join accepted it:\", JSON.stringify(joined));\ntry {\n fs.readFileSync(\"a\\0b\");\n} catch (e) {\n console.log(\"fs raised:\", e.code);\n}\n","expected_output":"join accepted it: \"a\\u0000b/c\"\nfs raised: ERR_INVALID_ARG_VALUE","verified_with":"Node.js v22.22.2"}],"fix":"Validate filenames for control characters (including NUL) at the point you accept them, not at the point you happen to open them — the path-building layer in between will not catch it for you.","related":[],"sources":[{"title":"Python docs: File Names, Command-Line Arguments, and Environment Variables","url":"https://docs.python.org/3/library/os.html#file-names-command-line-arguments-and-environment-variables"},{"title":"Node.js docs: fs — file paths","url":"https://nodejs.org/api/fs.html#file-paths"}],"tags":["filename","path","validation","upload","security"],"test_cases":[{"inputs":["a\u0000b"],"relation":"raises","note":"Your file-open code should raise here, not silently truncate the filename at the NUL byte."}]},{"id":"path-join-escapes-the-base-directory","title":"Joining a trusted folder with a filename doesn't confine the result to that folder","category":"filenames","assumption":"os.path.join(trusted_base, user_supplied_name) always produces a path inside trusted_base.","input":{"display":"\"../../etc/passwd\" joined onto \"/srv/uploads\"","description":"An upload handler or export feature that builds a save path from a base directory plus a user-controlled filename."},"what_goes_wrong":"The joined path points outside the upload directory entirely, letting a crafted filename read or overwrite files the base directory was supposed to contain.","why":"os.path.join concatenates path segments; it never checks whether a later argument walks back out of the first with `..` components, it just glues the strings together, and the escape only becomes visible once the OS resolves the dots at open time. Node's path.join goes further and actively normalizes `.` and `..` within the joined string itself, so the same escape is visible immediately in the returned string rather than only at the filesystem call. Either way, neither function treats its first argument as a security boundary; it is nothing more than the first string in a concatenation.","affects":["python os.path.join","node path.join","any 'join the base dir with the filename' upload handler"],"repros":[{"language":"python","status":"verified","code":"import os.path\njoined = os.path.join(\"/srv/uploads\", \"../../etc/passwd\")\nprint(joined)\nprint(joined.startswith(\"/srv/uploads\"))\n","expected_output":"/srv/uploads/../../etc/passwd\nTrue","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconst joined = path.join(\"/srv/uploads\", \"../../etc/passwd\");\nconsole.log(joined);\nconsole.log(joined.startsWith(\"/srv/uploads\"));\n","expected_output":"/etc/passwd\nfalse","verified_with":"Node.js v22.22.2"}],"fix":"After joining, resolve the result to an absolute, normalized path (os.path.realpath / path.resolve) and verify it still starts with the resolved base directory before touching the filesystem; reject anything that doesn't.","related":["absolute-second-argument-resets-join","windows-backslash-traversal-ntpath"],"sources":[{"title":"Python docs: os.path.join","url":"https://docs.python.org/3/library/os.path.html#os.path.join"},{"title":"Node.js docs: path.join","url":"https://nodejs.org/api/path.html#pathjoinpaths"}],"tags":["filename","path","upload","security","validation"],"test_cases":[{"inputs":["/srv/uploads","../../etc/passwd"],"relation":"no_crash","note":"Your path-builder should either refuse this filename or produce a path still inside the base directory — check which, don't assume."}]},{"id":"windows-backslash-traversal-ntpath","title":"A traversal filter that only blocks \"../\" misses \"..\\\\\" — and you can test the miss without touching Windows","category":"filenames","assumption":"Checking a filename for the substring \"../\" catches every directory-traversal attempt.","input":{"display":"\"..\\\\..\\\\windows\\\\system32\" (backslash-separated traversal)","description":"A filename accepted by a web form where the server later resolves the path on Windows, or a ZIP/tar entry name that uses backslashes."},"what_goes_wrong":"A blocklist written against the forward-slash form lets the backslash form straight through, and ntpath/path.win32 — which treat backslash as the canonical separator — walk back out of the intended directory exactly as the forward-slash form would.","why":"ntpath and path.win32 are pure-path implementations of Windows' own path rules, where backslash is the primary separator even though forward slash also works. A traversal check written only against the string \"../\" is really checking for a POSIX idiom and has nothing to say about \"..\\\\\", so on any code path that eventually resolves the string as a Windows path, the backslash form reaches the same `..` component and climbs out of the base directory just the same.","affects":["windows path resolution (backslash is the canonical separator)","ntpath (runs on any host, not just windows)","path.win32 (runs on any host, not just windows)"],"repros":[{"language":"python","status":"verified","code":"import ntpath\nraw = \"..\\\\..\\\\windows\\\\system32\"\nprint(\"contains ../ :\", \"../\" in raw)\njoined = ntpath.join(\"C:\\\\srv\\\\uploads\", raw)\nprint(\"joined:\", joined)\nprint(\"normalized:\", ntpath.normpath(joined))\n","expected_output":"contains ../ : False\njoined: C:\\srv\\uploads\\..\\..\\windows\\system32\nnormalized: C:\\windows\\system32","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconst raw = \"..\\\\..\\\\windows\\\\system32\";\nconsole.log(\"contains ../ :\", raw.includes(\"../\"));\nconst joined = path.win32.join(\"C:\\\\srv\\\\uploads\", raw);\nconsole.log(\"joined:\", joined);\n","expected_output":"contains ../ : false\njoined: C:\\windows\\system32","verified_with":"Node.js v22.22.2"}],"fix":"Normalize with the platform-appropriate path module and compare the RESULT against the resolved base directory; never pattern-match the raw string for one separator style.","related":["path-join-escapes-the-base-directory"],"sources":[{"title":"Python docs: os.path (ntpath on Windows)","url":"https://docs.python.org/3/library/os.path.html"},{"title":"Node.js docs: path.win32","url":"https://nodejs.org/api/path.html#pathwin32"}],"tags":["filename","path","security","validation","upload"],"test_cases":[{"inputs":["C:\\srv\\uploads","..\\..\\windows\\system32"],"relation":"no_crash","note":"A traversal filter tested only against forward slashes should also be tested against this backslash form before you trust it."}]},{"id":"windows-reserved-device-names","title":"A file named CON, NUL, or COM1 opens a device on Windows, not a file","category":"filenames","assumption":"A filename made of ordinary letters is always safe to create as a file.","input":{"display":"\"CON\", \"con.txt\", \"NUL\", \"COM1\", \"LPT1\"","description":"A user-supplied filename (upload name, export filename, log file name) that happens to match a reserved DOS device name."},"what_goes_wrong":"The app builds the path fine (ntpath/path.win32 never object), but the Win32 file APIs map the name to a legacy device instead of a file: writes to NUL vanish, CON is the console, COM1 is a serial port, so the 'file' is never created or the call fails far from where the name was accepted.","why":"CON, PRN, AUX, NUL, COM1-COM9 and LPT1-LPT9 (plus COM and LPT followed by the superscript digits ¹ ² ³) are reserved DOS device names that Win32 path normalization still recognizes for backward compatibility. Microsoft's naming rules also tell you to avoid them followed by an extension, stating that NUL.txt is equivalent to NUL; that held on every release before Windows 11, but .NET's path documentation notes that Windows 11 no longer always treats a name like CON.TXT as the device, so the extension form now depends on the Windows version. Pure path libraries only parse and join strings and never consult these rules, so ntpath.join and path.win32.join build every one of these paths without complaint.","affects":["windows win32 file apis (bare device names always; name-plus-extension forms before windows 11)","ntpath (no validation)","path.win32 (no validation)"],"repros":[{"language":"python","status":"verified","code":"import ntpath\nreserved = [\"CON\", \"con.txt\", \"NUL\", \"COM1\", \"LPT1\"]\nfor name in reserved:\n joined = ntpath.join(\"C:\\\\Users\\\\me\\\\Documents\", name)\n print(name, \"->\", joined)\n","expected_output":"CON -> C:\\Users\\me\\Documents\\CON\ncon.txt -> C:\\Users\\me\\Documents\\con.txt\nNUL -> C:\\Users\\me\\Documents\\NUL\nCOM1 -> C:\\Users\\me\\Documents\\COM1\nLPT1 -> C:\\Users\\me\\Documents\\LPT1","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconst reserved = [\"CON\", \"con.txt\", \"NUL\", \"COM1\", \"LPT1\"];\nfor (const name of reserved) {\n const joined = path.win32.join(\"C:\\\\Users\\\\me\\\\Documents\", name);\n console.log(name, \"->\", joined);\n}\n","expected_output":"CON -> C:\\Users\\me\\Documents\\CON\ncon.txt -> C:\\Users\\me\\Documents\\con.txt\nNUL -> C:\\Users\\me\\Documents\\NUL\nCOM1 -> C:\\Users\\me\\Documents\\COM1\nLPT1 -> C:\\Users\\me\\Documents\\LPT1","verified_with":"Node.js v22.22.2"},{"language":"windows","status":"documented","note":"Opening CON, NUL, COM1 or LPT1 through the normal Win32 CreateFile path opens the device of that name rather than creating a file. Before Windows 11, con.txt behaved the same way; Windows 11 changed how name-plus-extension forms are interpreted. Every pure-path join above accepted all five names.","source":"https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"}],"fix":"Reject user-supplied filenames whose base name (the text before the first dot, compared case-insensitively) is a reserved device name, including the extension forms, before touching the filesystem; reject them on every Windows version rather than relying on newer releases being more lenient.","related":[],"sources":[{"title":"Microsoft Learn: Naming Files, Paths, and Namespaces","url":"https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"},{"title":"Microsoft Learn: File path formats on Windows systems (Handle legacy devices)","url":"https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats"}],"tags":["filename","path","upload","validation","security","windows"],"test_cases":[{"inputs":["CON.txt"],"relation":"no_crash","note":"Your filename validator should flag this (a reserved device name with an extension) before any file is created on Windows, not after."},{"inputs":["nul"],"relation":"no_crash","note":"Reserved names are case-insensitive; a validator should flag lowercase 'nul' too."}]},{"id":"windows-trailing-dot-space-stripped","title":"\"report.\" and \"report \" are the same file as \"report\" on Windows, but not to your path library","category":"filenames","assumption":"Two filename strings that differ only by a trailing dot or space refer to two different files.","input":{"display":"\"report.\" and \"report \" (both compared against \"report\")","description":"A filename built by concatenating a title with a trailing space, or a name ending in a stray period from a stripped extension."},"what_goes_wrong":"A dedup or uniqueness check built on string comparison treats \"report.\" and \"report\" as different names, but Win32 silently drops the trailing dot/space when it actually creates the file, so the second create overwrites the first, or the app can't find the file it thinks it just wrote.","why":"Win32 normalizes almost every path it is given (the same step GetFullPathName performs), and one rule of that normalization is that when the path does not end in a separator, trailing periods and spaces are removed from the last component. Only paths that start with the \\\\?\\ prefix skip that step, which is why a file named \"report.\" can exist on NTFS yet be unreachable by ordinary programs. Pure path libraries perform no such rewrite; PureWindowsPath and path.win32 preserve the trailing characters exactly and report the strings as different, the opposite of what Windows will do with them.","affects":["windows win32 path normalization (strips trailing dots and spaces unless the path starts with \\\\?\\)","pathlib.PureWindowsPath (no stripping)","path.win32 (no stripping)"],"repros":[{"language":"python","status":"verified","code":"from pathlib import PureWindowsPath\na = PureWindowsPath(\"C:/Users/me/report.\")\nb = PureWindowsPath(\"C:/Users/me/report\")\nc = PureWindowsPath(\"C:/Users/me/report \")\nprint(a.name, \"==\", b.name, \"?\", a == b)\nprint(repr(c.name), \"==\", repr(b.name), \"?\", c == b)\n","expected_output":"report. == report ? False\n'report ' == 'report' ? False","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"import path from 'node:path';\nconst a = path.win32.basename(\"C:/Users/me/report.\");\nconst b = path.win32.basename(\"C:/Users/me/report\");\nconst c = path.win32.basename(\"C:/Users/me/report \");\nconsole.log(a, \"==\", b, \"?\", a === b);\nconsole.log(JSON.stringify(c), \"==\", JSON.stringify(b), \"?\", c === b);\n","expected_output":"report. == report ? false\n\"report \" == \"report\" ? false","verified_with":"Node.js v22.22.2"},{"language":"windows","status":"documented","note":"Creating \"report.\" or \"report \" through the normal Win32 CreateFile path creates or opens \"report\": normalization drops the trailing dot or space before the filesystem sees the name, so both strings collide with a plain \"report\".","source":"https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats"}],"fix":"Strip trailing dots and spaces from each user-supplied name component yourself before comparing, storing or creating it, matching what Win32 normalization will do anyway; or reject such names outright.","related":["case-insensitive-filesystem-pure-path-mismatch"],"sources":[{"title":"Microsoft Learn: File path formats on Windows systems (Trim characters)","url":"https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats"},{"title":"Microsoft Learn: Naming Files, Paths, and Namespaces","url":"https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"}],"tags":["filename","path","validation","upload","security","windows"],"test_cases":[{"inputs":["report.","report"],"relation":"equal","note":"A filename-key function meant to be safe on Windows should map these to the same key, because Windows will create the same file for both."},{"inputs":["report ","report"],"relation":"equal","note":"Same for a trailing space."}]},{"id":"decimal-from-float-vs-string","title":"Decimal(0.1) is not Decimal(\"0.1\") - switching to Decimal doesn't fix the bug if you build it from a float","category":"float-money","assumption":"Wrapping a problematic float in decimal.Decimal() makes it exact, regardless of how the Decimal was constructed.","input":{"display":"decimal.Decimal(0.1) vs decimal.Decimal(\"0.1\")","description":"A developer who hits a float rounding bug in money code and \"fixes\" it by calling Decimal() on an existing float variable, instead of re-reading the original value as a string."},"what_goes_wrong":"`Decimal(0.1)` prints as `Decimal('0.1000000000000000055511151231257827021181583404541015625')` - Decimal faithfully captured the float's actual binary value, not the clean decimal 0.1 - while `Decimal(\"0.1\")` is exactly `Decimal('0.1')`. The two are unequal, so money code that parses a string to a float and only then wraps it in Decimal has already lost precision before Decimal ever runs.","why":"`decimal.Decimal` is exact in base 10, but it can only be as exact as the input it's given. Passed a `float`, the constructor reads out that float's true (imprecise) IEEE-754 double value in full and stores exactly that - it cannot recover the \"0.1\" a human typed, because by the time it's a float, that information is already gone. Passed a `str`, it parses the decimal digits directly with no binary detour, so it is exact. JavaScript has no built-in decimal type (TC39's Decimal proposal is not part of the language), but the JavaScript repro shows the same fact from the other side: the Number written as 0.1 holds exactly 0.1000000000000000055511151231257827021181583404541015625, so any decimal library constructed from that Number rather than from the string \"0.1\" starts from the wrong value.","affects":["python"],"repros":[{"language":"python","status":"verified","code":"from decimal import Decimal\na = Decimal(0.1)\nb = Decimal(\"0.1\")\nprint(a)\nprint(b)\nprint(a == b)\nprint(Decimal(0.1) + Decimal(0.2) == Decimal(\"0.3\"))\nprint(Decimal(\"0.1\") + Decimal(\"0.2\") == Decimal(\"0.3\"))\n","expected_output":"0.1000000000000000055511151231257827021181583404541015625\n0.1\nFalse\nFalse\nTrue","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// No built-in Decimal, but toFixed can print the exact binary value a Number holds;\n// any decimal library constructed from this Number starts from that value, not \"0.1\".\nconst x = 0.1;\nconsole.log(String(x));\nconsole.log(x.toFixed(55));\nconsole.log((0.1 + 0.2).toFixed(55));\n","expected_output":"0.1\n0.1000000000000000055511151231257827021181583404541015625\n0.3000000000000000444089209850062616169452667236328125000","verified_with":"Node.js v22.22.2"}],"fix":"Always construct Decimal (or any exact money type) from the original string the user typed or the database stored - never from a float that has already rounded the value. If a float is all you have, treat it as already-corrupted and document the tolerance you're accepting.","related":["float-anchor-0-1-plus-0-2","python-round-2-675-float-imprecision"],"sources":[{"title":"Python docs: decimal - Decimal fixed point and floating point arithmetic","url":"https://docs.python.org/3/library/decimal.html"},{"title":"Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations","url":"https://docs.python.org/3/tutorial/floatingpoint.html"},{"title":"TC39 proposal: Decimal","url":"https://github.com/tc39/proposal-decimal"}],"tags":["money","currency","price","accounting","invoice","rounding"],"test_cases":[{"inputs":["0.1",0.1],"relation":"no_crash","note":"A their_make_decimal(value) helper should document whether it accepts floats at all; if it does, Decimal(0.1) != Decimal(\"0.1\") must not be silently assumed equal downstream."}]},{"id":"float-anchor-0-1-plus-0-2","title":"0.1 + 0.2 comes out to 0.30000000000000004","category":"float-money","assumption":"Adding 0.1 and 0.2 gives exactly 0.3.","input":{"display":"0.1 + 0.2 (two IEEE-754 double-precision literals)","description":"Any two decimal prices or quantities added directly, e.g. a $0.10 item plus a $0.20 item, or a 10% discount combined with a 20% discount."},"what_goes_wrong":"The sum prints as 0.30000000000000004 and `0.1 + 0.2 == 0.3` is false, so an equality check on a computed total silently fails.","why":"0.1 and 0.2 have no exact finite binary fraction (only sums of negative powers of two are exact in IEEE-754 double), so each literal is already rounded to the nearest representable double before the addition happens. Adding those two already-rounded values produces a result that is close to but not exactly the double closest to 0.3. This is a property of binary floating point itself (IEEE 754-2019), not a defect in any one language or runtime.","affects":["python","javascript","java","c","any IEEE-754 double"],"repros":[{"language":"python","status":"verified","code":"a = 0.1 + 0.2\nprint(a)\nprint(a == 0.3)\nprint(f\"{a:.20f}\")\n","expected_output":"0.30000000000000004\nFalse\n0.30000000000000004441","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const a = 0.1 + 0.2;\nconsole.log(a);\nconsole.log(a === 0.3);\nconsole.log(a.toFixed(20));\n","expected_output":"0.30000000000000004\nfalse\n0.30000000000000004441","verified_with":"Node.js v22.22.2"}],"fix":"Never compare floating-point money values for equality. Store money as integer minor units (cents) or use a fixed/decimal type (Python's decimal.Decimal, a cents-as-integer scheme in JS) for anything that gets added, subtracted, or compared.","related":["sum-ten-dimes-not-a-dollar","decimal-from-float-vs-string"],"sources":[{"title":"Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations","url":"https://docs.python.org/3/tutorial/floatingpoint.html"},{"title":"What Every Computer Scientist Should Know About Floating-Point Arithmetic (Goldberg, hosted by Oracle)","url":"https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html"}],"tags":["money","rounding","currency","accounting","price","comparison","validation"],"test_cases":[{"inputs":[0.1,0.2],"relation":"no_crash","note":"A money-adding function should never assume their_function(0.1, 0.2) == 0.3 by direct float comparison; it should return an exact minor-unit integer or Decimal instead."}]},{"id":"intl-number-format-de-de-roundtrip-misparsed","title":"Format a price for Germany, then parseFloat it back, and 1234.56 becomes 1.234","category":"float-money","assumption":"A number you formatted for display with Intl.NumberFormat can be read back with parseFloat to recover the same value.","input":{"display":"parseFloat(new Intl.NumberFormat(\"de-DE\").format(1234.56))","description":"A price formatted for a German user as \"1.234,56\" (period as thousands separator, comma as decimal separator) that later gets fed back into the same parsing code path used for US-formatted input, e.g. a form field pre-filled with a locale-formatted value and then re-parsed on submit."},"what_goes_wrong":"`new Intl.NumberFormat(\"de-DE\").format(1234.56)` correctly produces the German-formatted string \"1.234,56\" - but `parseFloat(\"1.234,56\")` reads that back as `1.234`, not `1234.56`: it treats the German thousands-separating period as a decimal point and stops at the comma.","why":"`Intl.NumberFormat` is locale-aware in one direction only: it formats a number according to the target locale's conventions (in de-DE, \".\" groups thousands and \",\" is the decimal mark). `parseFloat`, by contrast, is not locale-aware at all - it only ever understands JavaScript's own numeric literal syntax, where \".\" is always the decimal point. Feeding a de-DE-formatted string into a locale-blind parser silently reinterprets the grouping separator as the decimal point, truncating the value by roughly three orders of magnitude.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"const formatted = new Intl.NumberFormat(\"de-DE\").format(1234.56);\nconsole.log(formatted);\nconsole.log(parseFloat(formatted));\n","expected_output":"1.234,56\n1.234","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python's stdlib has no locale-formatting function comparable to Intl.NumberFormat\n# without opting into the system locale database; the bug is demonstrated directly\n# by parsing a German-formatted string the same naive way.\nformatted = \"1.234,56\"\ntry:\n print(float(formatted))\nexcept ValueError as e:\n print(\"ValueError:\", e)\n","expected_output":"ValueError: could not convert string to float: '1.234,56'","verified_with":"Python 3.14.2"}],"fix":"Never round-trip a locale-formatted display string through a locale-blind parser. Keep the underlying numeric value (not its formatted string) as the source of truth, and if you must parse locale-formatted input, use `Intl.NumberFormat(locale).formatToParts()` to discover that locale's actual group/decimal characters first.","related":["parsefloat-comma-thousands-separator"],"sources":[{"title":"MDN: Intl.NumberFormat","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat"},{"title":"MDN: parseFloat()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseFloat"}],"tags":["money","price","currency","i18n","forms","validation"],"test_cases":[{"inputs":["1.234,56","de-DE"],"relation":"returns","expected":1234.56,"note":"A their_parse_price(str, locale) function given the de-DE-formatted \"1.234,56\" should return 1234.56, not the 1.234 that locale-blind parseFloat gives."}]},{"id":"iso4217-minor-units-not-always-two","title":"Converting dollars to \"cents\" by multiplying by 100 is wrong for most of the world's currencies","category":"float-money","assumption":"Every currency has exactly 2 decimal places, so multiplying a major-unit amount by 100 always gives the correct minor-unit (cents) integer.","input":{"display":"100 * amountInJPY (0 decimal places), 100 * amountInBHD (3 decimal places), 100 * amountInCLF (4 decimal places)","description":"A payments system that stores money as an integer and converts user-entered major-unit amounts (\"¥1000\", \"BD 1.500\") to that integer by hardcoding `* 100`, instead of looking up the currency's actual exponent."},"what_goes_wrong":"A ¥1000 (1000 yen) charge hardcoded as `1000 * 100` becomes 100000 minor units - but JPY has no minor unit, so the processor reads that as ¥100000, a 100x overcharge. A BD1.500 (1.5 Bahraini dinar) charge hardcoded the same way becomes 150 minor units - but BHD's minor unit (the fils) is 1/1000 of a dinar, so 150 fils is only BD0.150, a 10x undercharge.","why":"ISO 4217 assigns each currency a number of minor-unit decimal digits, not a universal 2: JPY and KRW have 0, the dinars BHD, IQD, JOD, KWD, LYD and OMR plus TND have 3, and the two units of account CLF (Chile's Unidad de Fomento) and UYW have 4. A literal `* 100` bakes in the 2-digit assumption and mis-scales every currency that isn't one of the majority with exactly 2. `Intl.NumberFormat`'s `resolvedOptions().maximumFractionDigits` is a helpful hint but not an ISO 4217 lookup: V8 takes currency digits from ICU's copy of Unicode CLDR, which is display data and deliberately differs from ISO for some currencies. In Node 22 (ICU 78.2, CLDR 48) it reports 0 for IQD (ISO 4217: 3) and 0 for HUF (ISO 4217: 2), because those sub-units are not used in practice.","affects":["javascript","python","any payment integration that hardcodes a decimal-to-minor-unit conversion"],"repros":[{"language":"javascript","status":"verified","code":"const currencies = [\"USD\", \"JPY\", \"KRW\", \"BHD\", \"KWD\", \"CLF\", \"IQD\", \"HUF\"];\nfor (const c of currencies) {\n const digits = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: c }).resolvedOptions().maximumFractionDigits;\n console.log(c, digits);\n}\n// ISO 4217 says IQD = 3 and HUF = 2; Intl's CLDR-derived display digits differ\n\n// the bug: hardcoding *100 assumes digits is always 2\nfunction toMinorUnitsBuggy(amount) { return Math.round(amount * 100); }\nconsole.log(\"JPY 1000 buggy minor units:\", toMinorUnitsBuggy(1000), \"(should be 1000, no sub-unit)\");\nconsole.log(\"BHD 1.5 buggy minor units:\", toMinorUnitsBuggy(1.5), \"(should be 1500 fils, not 150)\");\n","expected_output":"USD 2\nJPY 0\nKRW 0\nBHD 3\nKWD 3\nCLF 4\nIQD 0\nHUF 0\nJPY 1000 buggy minor units: 100000 (should be 1000, no sub-unit)\nBHD 1.5 buggy minor units: 150 (should be 1500 fils, not 150)","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python's stdlib has no currency-aware formatter, so the correct exponents are\n# looked up from a small table taken from the ISO 4217 minor-unit list.\nminor_unit_digits = {\"USD\": 2, \"JPY\": 0, \"KRW\": 0, \"BHD\": 3, \"KWD\": 3, \"CLF\": 4}\n\ndef to_minor_units_buggy(amount):\n return round(amount * 100)\n\ndef to_minor_units_correct(amount, currency):\n return round(amount * (10 ** minor_unit_digits[currency]))\n\nprint(\"JPY 1000 buggy:\", to_minor_units_buggy(1000), \"correct:\", to_minor_units_correct(1000, \"JPY\"))\nprint(\"BHD 1.5 buggy:\", to_minor_units_buggy(1.5), \"correct:\", to_minor_units_correct(1.5, \"BHD\"))\n","expected_output":"JPY 1000 buggy: 100000 correct: 1000\nBHD 1.5 buggy: 150 correct: 1500","verified_with":"Python 3.14.2"}],"fix":"Store each currency's minor-unit exponent explicitly, taken from the table your payment processor or ledger uses (normally ISO 4217, sometimes with processor-specific exceptions), and convert with `amount * 10 ** exponent` on an exact decimal value, not with a hardcoded `* 100`. Do not use `Intl.NumberFormat(...).resolvedOptions().maximumFractionDigits` as the source of truth for stored amounts: it comes from CLDR display data and disagrees with ISO 4217 for currencies such as IQD and HUF.","related":["float-anchor-0-1-plus-0-2"],"sources":[{"title":"ISO 4217 currency codes and minor unit data (SIX Group, official ISO 4217 maintenance agency)","url":"https://www.six-group.com/en/products-services/financial-information/data-standards.html"},{"title":"MDN: Intl.NumberFormat.prototype.resolvedOptions()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/resolvedOptions"},{"title":"Unicode CLDR supplemental currency data (digits used by ICU/Intl)","url":"https://github.com/unicode-org/cldr/blob/main/common/supplemental/supplementalData.xml"}],"tags":["money","currency","price","invoice","accounting","i18n","validation"],"test_cases":[{"inputs":[1000,"JPY"],"relation":"returns","expected":1000,"note":"their_function(amount, currency) that converts to minor units should return 1000 for ¥1000, not 100000."},{"inputs":[1.5,"BHD"],"relation":"returns","expected":1500,"note":"their_function(amount, currency) should return 1500 fils for BD1.500, not 150."}]},{"id":"math-abs-negative-zero","title":"-0 === 0 is true, yet 1 / -0 is -Infinity: JavaScript has a negative zero that equality and printing both hide","category":"float-money","assumption":"A number that compares equal to 0 is simply zero; there is nothing more to know about it.","input":{"display":"-0 === 0 (true) but Object.is(-0, 0) === false, and 1 / -0 === -Infinity; Math.abs(-0) is +0","description":"A computed value that lands on exactly zero from the negative side -- e.g. a difference, a trig/rounding result, or a rate-of-change calculation -- then divided or compared downstream."},"what_goes_wrong":"A value that is mathematically zero but carries a negative sign bit compares equal to positive zero everywhere you'd check (`===`, `==`), so it passes any 'is this zero' test silently -- and then produces `-Infinity` instead of `+Infinity` the moment something divides by it, with no earlier signal that the sign mattered.","why":"IEEE 754 floating point has two distinct zeros, +0 and -0, which differ only in their sign bit, and ordinary code produces -0 easily: `Math.round(-0.4)`, `-1 * 0`, `0 / -5`. JavaScript's `===` treats them as equal (IsStrictlyEqual uses Number::equal, which special-cases +0 and -0), as do `==`, `Array.prototype.includes` and `Set`, and `String(-0)`, template literals and `JSON.stringify(-0)` all print \"0\", so the sign is invisible in logs. `Object.is()` exists specifically to expose the difference, using the SameValue algorithm. The sign bit survives arithmetic that `===` hides, which is why `1 / -0` and `1 / 0` differ. `Math.abs(-0)` does return +0, so taking the absolute value is one reliable way to normalise it.","affects":["javascript","python"],"repros":[{"language":"javascript","status":"verified","code":"console.log(Math.abs(-0));\nconsole.log(-0 === 0, Object.is(-0, 0));\nconsole.log(1 / -0, 1 / 0);\n","expected_output":"0\ntrue false\n-Infinity Infinity","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"print(abs(-0.0))\nprint(-0.0 == 0.0)\nimport math\nprint(math.copysign(1, -0.0)) # the sign survives, exposed explicitly\ntry:\n print(1 / -0.0)\nexcept ZeroDivisionError as e:\n print(\"ZeroDivisionError:\", e) # Python refuses rather than returning -inf\n","expected_output":"0.0\nTrue\n-1.0\nZeroDivisionError: division by zero","verified_with":"Python 3.14.2"}],"fix":"If the sign of a zero result matters to your calculation (e.g. distinguishing 'approached from below' from 'approached from above'), check it explicitly with `Object.is(x, -0)` in JS or `math.copysign(1, x) < 0` in Python; don't rely on `===`/`==` to reveal it, since both treat -0 and 0 as equal.","related":[],"sources":[{"title":"IEEE 754-2019 (floating-point arithmetic, signed zero)","url":"https://ieeexplore.ieee.org/document/8766229"},{"title":"MDN: Object.is()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is"},{"title":"ECMA-262: IsStrictlyEqual","url":"https://tc39.es/ecma262/#sec-isstrictlyequal"}],"tags":["validation","comparison"],"test_cases":[]},{"id":"max-safe-integer-plus-two","title":"2**53 + 1 is a different number in Python than it is once it becomes a float","category":"float-money","assumption":"Adding 1 to a very large whole number (like an invoice ID or a total expressed in minor units) always produces a distinguishable result.","input":{"display":"2**53 + 1 as a Python int vs as a JS Number/float","description":"An invoice total or running ledger balance expressed in cents that has grown past 9,007,199,254,740,992 (Number.MAX_SAFE_INTEGER) - implausible for a single invoice, plausible for a cumulative all-time platform total - and then passed through a float or JSON.parse in JavaScript."},"what_goes_wrong":"In Python, `2**53 + 1 == 2**53` is False - they're exact, distinct integers. The moment either is converted to (or ever existed only as) an IEEE-754 double, `float(2**53 + 1) == float(2**53)` is True: both round to the identical double, because doubles only have 53 bits of mantissa and the gap between representable values at that magnitude is already 2.","why":"A double has a 52-bit stored mantissa (53 bits of precision with the implicit leading bit), so it can represent every integer exactly only up to 2**53; above that, the spacing between representable doubles grows (it's 2 between 2**53 and 2**54, then 4, and so on), so 2**53 + 1 has no exact double representation and rounds to the nearest representable value, 2**53 itself. Python's native `int` is arbitrary precision and unaffected, which is exactly why this only bites once a number crosses into a float - via JSON, via JavaScript's single Number type, via a database driver that returns a float, etc.","affects":["javascript","any IEEE-754 double used to hold an integer id or count"],"repros":[{"language":"python","status":"verified","code":"a = 2**53\nb = 2**53 + 1\nprint(a == b)\nprint(float(a) == float(b))\n","expected_output":"False\nTrue","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const a = 2 ** 53;\nconst b = 2 ** 53 + 1;\nconsole.log(a === b);\nconsole.log(Number.isSafeInteger(a), Number.isSafeInteger(b));\nconsole.log(Number.MAX_SAFE_INTEGER);\n","expected_output":"true\nfalse false\n9007199254740991","verified_with":"Node.js v22.22.2"}],"fix":"Never put an invoice ID, account balance, or any value that might exceed 2**53 (~9 quadrillion) through JavaScript's Number type or through JSON if the source has more precision - use a string for the ID/balance in JSON and parse it with a BigInt or arbitrary-precision type on the receiving end.","related":["float-anchor-0-1-plus-0-2","json-big-integer-precision-loss"],"sources":[{"title":"MDN: Number.MAX_SAFE_INTEGER","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER"},{"title":"ECMA-262: Number.isSafeInteger","url":"https://tc39.es/ecma262/#sec-number.issafeinteger"}],"tags":["money","accounting","invoice","id","database"],"test_cases":[{"inputs":[9007199254740992,9007199254740992],"relation":"not_equal","note":"their_function(x) that stores or compares large integer ids/totals must still tell 9007199254740992 and 9007199254740993 apart afterward; routing them through a float/JS Number makes them identical."}]},{"id":"naive-loop-sum-order-dependence","title":"The same three numbers sum to 0.0 or 1.0 depending only on the order you add them in","category":"float-money","assumption":"Addition is associative, so summing a list of amounts gives the same total no matter what order you add them in.","input":{"display":"a loop totaling [1e16, 1.0, -1e16] vs the same three values reordered to [1e16, -1e16, 1.0]","description":"Totaling line items where one amount is vastly larger than another (a huge negotiated credit alongside a one-dollar fee, or a running grand-total accumulator next to a small adjustment), accumulated one at a time with `total += amount`."},"what_goes_wrong":"A hand-written accumulator totals [1e16, 1.0, -1e16] as 0.0 but totals the reordered [1e16, -1e16, 1.0] as 1.0 - the mathematically identical sum of the same three numbers, in a different order, gives a different floating-point answer.","why":"Adding 1.0 to 1e16 first has no effect at all: doubles near 1e16 are spaced 2 apart, so 1e16 + 1.0 lands exactly halfway between 1e16 and 1e16 + 2, and IEEE-754's round-half-to-even rule picks 1e16. The 1.0 is rounded away before the later subtraction of 1e16 ever sees it, leaving 0.0. Doing the subtraction first (1e16 - 1e16 = 0.0 exactly) leaves the 1.0 intact to be added last, giving 1.0. Floating-point addition is commutative but not associative, because each intermediate result is independently rounded. Python's `math.fsum` tracks the lost low-order bits and returns the exactly rounded 1.0 for both orderings (since Python 3.12 the built-in `sum()` also gives 1.0 here, via compensated summation); Node 22 has no standard-library equivalent (`Math.sumPrecise` is a TC39 proposal it does not ship).","affects":["python","javascript","any naive left-to-right float accumulation"],"repros":[{"language":"python","status":"verified","code":"import math\n\ndef naive_sum(xs):\n total = 0.0\n for x in xs:\n total += x\n return total\n\na = [1e16, 1.0, -1e16]\nb = [1e16, -1e16, 1.0]\nprint(naive_sum(a), naive_sum(b))\nprint(math.fsum(a), math.fsum(b))\n","expected_output":"0.0 1.0\n1.0 1.0","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function naiveSum(xs) { return xs.reduce((a, b) => a + b, 0); }\nconst a = [1e16, 1.0, -1e16];\nconst b = [1e16, -1e16, 1.0];\nconsole.log(naiveSum(a), naiveSum(b));\n","expected_output":"0 1","verified_with":"Node.js v22.22.2"}],"fix":"Don't assume reordering a sum is safe once magnitudes differ wildly. In Python, use `math.fsum()` for an exactly rounded, order-independent total. In JavaScript (Node 22) there is no built-in - use a compensated-summation (Kahan/Neumaier) helper, or (best for money) work in integer minor units, which have no rounding error to begin with as long as totals stay below 2**53.","related":["sum-ten-dimes-not-a-dollar","float-anchor-0-1-plus-0-2"],"sources":[{"title":"Python docs: math.fsum()","url":"https://docs.python.org/3/library/math.html#math.fsum"},{"title":"What Every Computer Scientist Should Know About Floating-Point Arithmetic (Goldberg, hosted by Oracle)","url":"https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html"}],"tags":["money","accounting","invoice","price","rounding"],"test_cases":[{"inputs":[[10000000000000000,1,-10000000000000000],[10000000000000000,-10000000000000000,1]],"relation":"equal","note":"their_sum(list) should give the same total for both orderings of these three numbers (1.0 is the mathematically correct answer for both) - a naive left-to-right accumulator gives 0.0 for the first and 1.0 for the second."}]},{"id":"nan-breaks-equality-and-max-min","title":"NaN != NaN, and Python's max() picks a different \"largest\" value depending on where the NaN sits in the list","category":"float-money","assumption":"max() and min() over a list of numbers return the mathematically largest/smallest value, regardless of order, and a value always equals itself.","input":{"display":"max([float('nan'), 1]) vs max([1, float('nan')])","description":"A price list, exchange-rate feed, or sensor reading where a bad or missing value has been coerced to NaN (e.g. a failed `float()` parse or a 0/0 division) and then run through max()/min() to find a best price."},"what_goes_wrong":"`float('nan') != float('nan')` is true (a NaN never equals even itself). Worse, Python's `max([nan, 1])` returns `nan`, but `max([1, nan])` - the same two values in the other order - returns `1`: whichever element happens to be compared against the running maximum first determines whether the NaN \"wins\". JavaScript's `Math.max` doesn't have this order sensitivity - it returns `NaN` regardless of where the NaN sits in the argument list.","why":"By IEEE 754, every comparison involving NaN (`<`, `>`, `<=`, `>=`, `==`) returns false. Python's built-in `max`/`min` are implemented as a linear scan that keeps a running best value and replaces it only when `candidate > running_best` is true; since any comparison against a NaN is false, the running best only changes to NaN if NaN happens to be the very first element compared (so it never gets replaced), but if NaN appears later, `nan > current_best` is false, so the NaN is skipped and the earlier value is kept. JavaScript's `Math.max` is specified to return NaN if any argument is NaN, checked independently of ordering, which is why it doesn't show the same asymmetry.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"nan = float(\"nan\")\nprint(nan != nan)\nprint(max([nan, 1]), max([1, nan]))\nprint(min([nan, 1]), min([1, nan]))\n","expected_output":"True\nnan 1\nnan 1","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"console.log(NaN !== NaN);\nconsole.log(Math.max(NaN, 1), Math.max(1, NaN));\nconsole.log(Math.min(NaN, 1), Math.min(1, NaN));\n","expected_output":"true\nNaN NaN\nNaN NaN","verified_with":"Node.js v22.22.2"}],"fix":"Filter out NaN values before calling max()/min() (e.g. `math.isnan`/`Number.isNaN`), rather than relying on max/min to handle them - and never use `==`/`!=` to check for a missing or invalid price; test for NaN explicitly.","related":[],"sources":[{"title":"Python docs: max()","url":"https://docs.python.org/3/library/functions.html#max"},{"title":"MDN: Math.max()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max"},{"title":"IEEE 754-2019 (comparison predicates involving NaN, summarized in Python's math module docs)","url":"https://docs.python.org/3/library/math.html#math.isnan"}],"tags":["money","price","accounting","comparison","validation","sorting"],"test_cases":[{"inputs":[["NaN","1"],["1","NaN"]],"relation":"equal","note":"A their_best_price(prices) function over price strings that may include an unparseable/NaN entry must give the same answer whatever order the list is in (filter or reject NaN explicitly); Python's max() over the floats gives nan for one order and 1.0 for the other."}]},{"id":"negative-zero-balance-formatting","title":"A $0.00 balance can print as \"-0.00\" - and Python and JavaScript disagree on when","category":"float-money","assumption":"A balance that computes to zero always displays as \"0.00\", regardless of how it was computed.","input":{"display":"0.0 * -1, or -0.0, formatted as currency","description":"A zero balance that went through a multiplication or rounding with a negative operand: a zero quantity times a negative unit price (`0 * -4.99`), negating a zero balance (`-balance`), or rounding a tiny negative remainder to cents (`round(-0.004, 2)` in Python is -0.0). Note that plain subtraction to zero (`-5.0 + 5.0`) gives +0, not -0."},"what_goes_wrong":"Python's f\"{balance:.2f}\" on that zero prints \"-0.00\", showing a customer a balance that looks negative when it is exactly zero. JavaScript's `(balance).toFixed(2)` on the identical IEEE-754 value prints \"0.00\" - the sign silently disappears - and `JSON.stringify(balance)` serializes it as plain `0`. But JavaScript is not immune: a tiny negative amount such as -0.004 prints as \"-0.00\" from `toFixed(2)` in both languages.","why":"IEEE-754 doubles have two zeros, +0 and -0, which compare equal (`-0.0 == 0.0` and `-0 === 0` are both true) but are distinguishable (`Object.is(-0, 0)` is false, and `math.copysign(1, -0.0)` is -1.0). Python's float formatting renders the sign bit faithfully, so -0.0 becomes \"-0.00\". In ECMAScript, `toFixed` works on the mathematical value of the number, which for -0 is plain 0, and only emits \"-\" when that value is below zero - so -0 loses its sign but -0.004 keeps it and rounds to \"-0.00\". `JSON.stringify` goes through Number::toString, which the spec defines to return \"0\" for both zeros.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import math\nbalance = 0.0 * -1\nprint(balance)\nprint(balance == 0.0)\nprint(f\"{balance:.2f}\")\nprint(math.copysign(1, balance))\nprint(f\"{balance:z.2f}\", f\"{-0.004:.2f}\", f\"{balance + 0:.2f}\")\n","expected_output":"-0.0\nTrue\n-0.00\n-1.0\n0.00 -0.00 0.00","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const balance = 0 * -1;\nconsole.log(balance);\nconsole.log(Object.is(balance, 0), balance === 0);\nconsole.log(balance.toFixed(2));\nconsole.log(JSON.stringify(balance));\nconsole.log((-0.004).toFixed(2), Object.is(balance + 0, 0));\n","expected_output":"-0\nfalse true\n0.00\n0\n-0.00 true","verified_with":"Node.js v22.22.2"}],"fix":"Normalize before display or serialization. Adding positive zero works in both languages (`balance + 0` turns -0 into +0 under IEEE-754 round-to-nearest), and Python 3.11+ has a dedicated format flag: `f\"{balance:z.2f}\"` prints \"0.00\" for -0.0 and for small negatives that round to zero. Better still for money, round to integer cents first (`Math.round(x * 100)` / `round(x * 100)`), then format the integer, and normalize `-0` cents to 0.","related":["splitting-100-dollars-three-ways-leaves-a-cent"],"sources":[{"title":"IEEE 754-2019 (summary of signed zero via Python docs)","url":"https://docs.python.org/3/tutorial/floatingpoint.html"},{"title":"MDN: Object.is()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is"},{"title":"ECMA-262: Number::toString","url":"https://tc39.es/ecma262/#sec-numeric-types-number-tostring"}],"tags":["money","accounting","invoice","price","display","rounding"],"test_cases":[{"inputs":[0,0],"relation":"equal","note":"A balance-display function their_function(amount) should format 0.0 and -0.0 identically (both \"0.00\"), even though the underlying floats are distinguishable via Object.is / math.copysign."},{"inputs":[-0.004],"relation":"returns","expected":"0.00","note":"A cents formatter given a tiny negative remainder that rounds to zero should show \"0.00\", not \"-0.00\" (both Python's .2f and JavaScript's toFixed(2) print \"-0.00\" here)."}]},{"id":"number-epsilon-false-positive-small-magnitude","title":"Number.EPSILON as a tolerance says 1e-20 and 2e-20 are equal, even though one is exactly double the other","category":"float-money","assumption":"Comparing `Math.abs(a - b) <= Number.EPSILON` is a safe, general-purpose \"close enough to be equal\" check for any two floating-point numbers.","input":{"display":"Math.abs(1e-20 - 2e-20) <= Number.EPSILON","description":"A generic \"nearly equal\" helper copy-pasted into a pricing or rate-calculation codebase and applied to small fractional values (e.g. a per-unit rate, a tiny fee percentage, or a currency conversion remainder) as well as ordinary prices."},"what_goes_wrong":"`1e-20 !== 2e-20` correctly reports they're different numbers (one is literally double the other), but `Math.abs(1e-20 - 2e-20) <= Number.EPSILON` reports `true` - the \"nearly equal\" check says two values that differ by 100% of their own magnitude are equal.","why":"`Number.EPSILON` (about 2.22e-16) is the gap between 1 and the next representable double above it - it is an absolute tolerance calibrated for numbers near magnitude 1. For numbers much smaller than 1, that absolute tolerance is enormous relative to the values being compared, so genuinely different small numbers fall within it and are wrongly judged equal. (The mirror-image failure happens at large magnitudes: the gap between adjacent representable doubles grows past Number.EPSILON, so even values that are as close as floating point can represent are judged \"not equal\".) A tolerance has to scale with the magnitude of the numbers being compared - a fixed epsilon from the language's standard library does not do that for you.","affects":["javascript","python","any code using machine epsilon as an absolute tolerance"],"repros":[{"language":"javascript","status":"verified","code":"const a = 1e-20;\nconst b = 2e-20;\nconsole.log(a === b);\nconsole.log(Math.abs(a - b), Number.EPSILON);\nconsole.log(Math.abs(a - b) <= Number.EPSILON);\n","expected_output":"false\n1e-20 2.220446049250313e-16\ntrue","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"import sys\na = 1e-20\nb = 2e-20\nprint(a == b)\nprint(abs(a - b), sys.float_info.epsilon)\nprint(abs(a - b) <= sys.float_info.epsilon)\n","expected_output":"False\n1e-20 2.220446049250313e-16\nTrue","verified_with":"Python 3.14.2"}],"fix":"Use a tolerance that scales with the operands, e.g. `Math.abs(a - b) <= 1e-9 * Math.max(Math.abs(a), Math.abs(b))`, and add an absolute tolerance only if values near zero should count as equal in your domain. Python's `math.isclose(a, b)` does exactly this (relative `rel_tol=1e-09`, `abs_tol=0.0` by default) and returns False for 1e-20 vs 2e-20. Beware the common variant `Number.EPSILON * Math.max(|a|, |b|, 1)`: the `1` floor turns it back into an absolute EPSILON tolerance below magnitude 1, so it still calls 1e-20 and 2e-20 equal. For money, compare integer minor units instead of floats.","related":["float-anchor-0-1-plus-0-2"],"sources":[{"title":"MDN: Number.EPSILON","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/EPSILON"},{"title":"Python docs: sys.float_info","url":"https://docs.python.org/3/library/sys.html#sys.float_info"}],"tags":["money","price","comparison","validation","accounting"],"test_cases":[]},{"id":"parsefloat-comma-thousands-separator","title":"parseFloat(\"1,234.56\") is 1 - not 1234.56, not NaN","category":"float-money","assumption":"parseFloat on a reasonable-looking number string either parses the whole number correctly or fails loudly.","input":{"display":"parseFloat(\"1,234.56\")","description":"A price pasted from a spreadsheet, invoice, or a US-formatted input field (\"$1,234.56\") run through parseFloat to get a number."},"what_goes_wrong":"`parseFloat(\"1,234.56\")` returns `1` - it silently stops at the first character it can't parse (the comma) and returns whatever valid number prefix it found, rather than parsing the full value or raising an error.","why":"JavaScript's `parseFloat` is specified to skip leading whitespace, consume the longest prefix of the string that is valid decimal-literal syntax, and return that - it never looks at (or errors on) what comes after. A comma is not part of that syntax, so parsing stops right after the leading \"1\" and \",234.56\" is silently discarded. A leading \"$\" is not number syntax either, so `parseFloat(\"$1,234.56\")` finds no valid prefix at all and returns NaN. `Number()` is stricter - it requires the whole string to be numeric - so it returns NaN for both.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"console.log(parseFloat(\"1,234.56\"));\nconsole.log(parseFloat(\"$1,234.56\"));\nconsole.log(Number(\"1,234.56\"));\n","expected_output":"1\nNaN\nNaN","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"try:\n print(float(\"1,234.56\"))\nexcept ValueError as e:\n print(\"ValueError:\", e)\n","expected_output":"ValueError: could not convert string to float: '1,234.56'","verified_with":"Python 3.14.2"}],"fix":"Never feed locale-formatted user input straight into parseFloat/float(). Strip grouping separators explicitly (knowing which character is the grouping separator for the input's locale) before parsing, or use a locale-aware parser (e.g. derive the separators from `Intl.NumberFormat(locale).formatToParts(1234.5)` in JS) rather than a generic numeric parser.","related":["intl-number-format-de-de-roundtrip-misparsed"],"sources":[{"title":"MDN: parseFloat()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseFloat"},{"title":"ECMA-262: parseFloat","url":"https://tc39.es/ecma262/#sec-parsefloat-string"},{"title":"Python docs: float()","url":"https://docs.python.org/3/library/functions.html#float"}],"tags":["money","price","invoice","currency","i18n","validation","forms"],"test_cases":[{"inputs":["1,234.56"],"relation":"returns","expected":1234.56,"note":"A their_parse_price(input) function given US-formatted \"1,234.56\" should return 1234.56, not silently truncate to 1 like raw parseFloat/Number does."}]},{"id":"python-banker-rounding-vs-js-round-half-up","title":"round(2.5) is 2 in Python but Math.round(2.5) is 3 in JavaScript","category":"float-money","assumption":"Rounding a value that is exactly halfway between two integers always rounds up, in every language.","input":{"display":"round(2.5) in Python 3 vs Math.round(2.5) in JavaScript, and round(-2.5) vs Math.round(-2.5)","description":"A genuine tie - e.g. rounding a $2.50 half-unit quantity, or a tax amount that lands exactly on a half-cent after an earlier exact calculation - rounded to the nearest whole unit."},"what_goes_wrong":"The exact same halfway value rounds to a different integer depending on the language: Python's round(2.5) is 2 and round(3.5) is 4 (always to the nearest even integer), while JavaScript's Math.round(2.5) is 3 (always rounds .5 up, toward positive infinity) - and Math.round(-2.5) is -2, not -3, because \"up\" for Math.round means toward +Infinity even on the negative side.","why":"Python 3's round() implements round-half-to-even (\"banker's rounding\", IEEE 754's default rounding mode) specifically to avoid statistical bias from always rounding .5 the same direction over many values. JavaScript's Math.round() is specified (ECMA-262) to round half-way cases toward +Infinity, not away from zero and not to even - which is why -2.5 rounds to -2 (toward +Infinity means less negative) rather than -3. Neither behavior is a bug; they are different, deliberately documented rounding rules, and porting rounding logic between the two languages without accounting for the difference changes answers for every exact half.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"print(round(2.5), round(3.5), round(-2.5), round(-3.5))\n","expected_output":"2 4 -2 -4","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"console.log(Math.round(2.5), Math.round(3.5), Math.round(-2.5), Math.round(-3.5));\n","expected_output":"3 4 -2 -3","verified_with":"Node.js v22.22.2"}],"fix":"Decide and document one rounding rule for money (customer-facing amounts commonly use round-half-up, away from zero; some accounting contexts deliberately use half-even) and implement it explicitly - e.g. Python's decimal.Decimal.quantize(..., rounding=decimal.ROUND_HALF_UP) - rather than relying on whatever a language's built-in round happens to do.","related":["python-round-2-675-float-imprecision","decimal-from-float-vs-string"],"sources":[{"title":"Python docs: round()","url":"https://docs.python.org/3/library/functions.html#round"},{"title":"MDN: Math.round()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round"},{"title":"ECMA-262: Math.round","url":"https://tc39.es/ecma262/#sec-math.round"}],"tags":["money","rounding","price","accounting","invoice"],"test_cases":[{"inputs":[2.5],"relation":"no_crash","note":"A cross-language rounding function should not assume their_function(2.5) gives the same result as Math.round(2.5) in JS (3) or round(2.5) in Python (2) by default - pick one explicit rule and implement it the same way everywhere."}]},{"id":"python-round-2-675-float-imprecision","title":"round(2.675, 2) is 2.67 in Python, not 2.68","category":"float-money","assumption":"round(x, 2) rounds a price to the nearest cent the way a calculator would, based on the decimal digits you wrote.","input":{"display":"round(2.675, 2)","description":"Rounding a unit price quoted to three decimals, such as $2.675 from a supplier price list or a per-unit rate table, to the nearest cent before storing or displaying it."},"what_goes_wrong":"`round(2.675, 2)` returns 2.67, not 2.68, even though 2.675 looks exactly halfway between 2.67 and 2.68.","why":"The literal 2.675 is not exactly representable in IEEE-754 double; the nearest representable double is approximately 2.67499999999999982236..., which is strictly below the true halfway point. Python's round() rounds that actual stored value, so it correctly rounds down to 2.67 - the \"error\" is in the input's binary representation, not in round()'s rounding rule. This is the same mechanism as JavaScript's toFixed surprises, and it is independent of Python's round-half-to-even tie-breaking rule, because 2.67499999999999982236... isn't a tie to begin with.","affects":["python","any IEEE-754 double rounded by value rather than by decimal text"],"repros":[{"language":"python","status":"verified","code":"x = 2.675\nprint(round(x, 2))\nprint(f\"{x:.20f}\")\nimport decimal\nprint(decimal.Decimal(x))\n","expected_output":"2.67\n2.67499999999999982236\n2.67499999999999982236431605997495353221893310546875","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const x = 2.675;\nconsole.log(Number(x.toFixed(2)));\nconsole.log(x.toFixed(20));\n","expected_output":"2.67\n2.67499999999999982236","verified_with":"Node.js v22.22.2"}],"fix":"Build the number from a decimal string with decimal.Decimal(\"2.675\") instead of a float literal, and round that - Decimal preserves the exact decimal value, so round-half-even on it gives the answer a human expects. Or round integer cents directly, never a float dollar amount.","related":["tofixed-1-005-rounds-down-not-up","python-banker-rounding-vs-js-round-half-up","decimal-from-float-vs-string"],"sources":[{"title":"Python docs: round()","url":"https://docs.python.org/3/library/functions.html#round"},{"title":"Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations","url":"https://docs.python.org/3/tutorial/floatingpoint.html"}],"tags":["money","rounding","price","currency","invoice","accounting"],"test_cases":[{"inputs":["2.675"],"relation":"returns","expected":"2.68","note":"A price-rounding function given the exact decimal string \"2.675\" should round to \"2.68\" (round-half-up on the true decimal value); round(float(\"2.675\"), 2) in Python returns 2.67 instead."}]},{"id":"splitting-100-dollars-three-ways-leaves-a-cent","title":"Splitting $100.00 three ways and rounding each share loses a cent","category":"float-money","assumption":"Dividing a total into N equal shares and rounding each share to the nearest cent reconstructs the original total when you add the shares back up.","input":{"display":"10000 cents / 3, rounded, times 3","description":"Splitting a bill, a refund, or a revenue share three ways (or any way that doesn't divide evenly) and charging/crediting each party its rounded share."},"what_goes_wrong":"$100.00 split three ways gives 3333.33... cents per share; rounding each share to 3333 cents and charging three people that amount collects only $99.99 in total - one cent has vanished, and nobody's individual charge was wrong in isolation.","why":"10000 / 3 is not an integer, so any rounding of the per-share amount (3333.333... -> 3333) discards a fractional remainder. Doing that independently for each of the three shares discards the remainder three times instead of accounting for it once, so the sum of the rounded shares no longer equals the original total. This isn't a floating-point bug - it happens with exact integer-cent arithmetic too - it's a remainder-allocation bug: dividing a fixed amount into N parts leaves `total mod N` leftover units (here 1 cent) that something has to explicitly absorb.","affects":["python","javascript","any integer or float division used to split a shared amount"],"repros":[{"language":"python","status":"verified","code":"total_cents = 10000\nn = 3\nshare = round(total_cents / n)\nprint(share, share * n, total_cents - share * n)\n","expected_output":"3333 9999 1","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const totalCents = 10000;\nconst n = 3;\nconst share = Math.round(totalCents / n);\nconsole.log(share, share * n, totalCents - share * n);\n","expected_output":"3333 9999 1","verified_with":"Node.js v22.22.2"}],"fix":"Work in integer cents and allocate the remainder explicitly: every share gets `total // n`, and the first `total % n` shares get one extra cent (10000 split 3 ways -> [3334, 3333, 3333]). The shares then always sum to the exact original total and differ by at most one cent; which recipients get the extra cents is a product decision, but something must hand them out deliberately.","related":["negative-zero-balance-formatting"],"sources":[{"title":"Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations","url":"https://docs.python.org/3/tutorial/floatingpoint.html"}],"tags":["money","rounding","accounting","invoice","price"],"test_cases":[{"inputs":[10000,3],"relation":"returns","expected":[3334,3333,3333],"note":"A splitting function their_function(totalCents, n) should return shares that sum to exactly totalCents - e.g. [3334, 3333, 3333], not three independently-rounded 3333s that sum to 9999."}]},{"id":"sum-ten-dimes-not-a-dollar","title":"Ten 0.1s added in a loop don't equal 1 - but since Python 3.12, sum() gives exactly 1.0 and the loop still doesn't","category":"float-money","assumption":"Accumulating ten $0.10 charges one at a time gives exactly $1.00, and Python's sum() is just a loop in disguise.","input":{"display":"a loop adding 0.1 ten times, vs. sum([0.1] * 10)","description":"A running total built up line-by-line as items are scanned or charges applied, e.g. ten $0.10 surcharges added to an order as it is built."},"what_goes_wrong":"A hand-written accumulator (`total += 0.1` ten times) ends at 0.9999999999999999, not 1.0, in both Python and JavaScript - but on Python 3.12 and later the built-in `sum([0.1]*10)` prints exactly 1.0 (on 3.11 and earlier it printed 0.9999999999999999, same as the loop), so swapping a loop for `sum()`, or upgrading Python, silently changes the answer.","why":"Each addition rounds to the nearest representable double, and those small rounding errors compound across ten sequential additions, landing one ULP below 1.0. In Python 3.12, CPython's built-in `sum()` switched to compensated (Neumaier) summation for floats: it carries the rounding error of each addition in a separate correction term and adds it back at the end, so for this input it no longer reproduces the naive loop's error. It is not the same as `math.fsum` (which is exactly rounded for every input); the docs only promise \"higher accuracy and better commutativity on most builds\". JavaScript has no built-in equivalent in Node 22 - `reduce` and a hand-rolled loop behave identically because both are plain sequential addition.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"total = 0.0\nfor _ in range(10):\n total += 0.1\nprint(total, total == 1.0)\nprint(sum([0.1] * 10), sum([0.1] * 10) == 1.0)\n","expected_output":"0.9999999999999999 False\n1.0 True","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"let total = 0;\nfor (let i = 0; i < 10; i++) { total += 0.1; }\nconsole.log(total, total === 1);\nconst reduced = [0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1].reduce((a, b) => a + b, 0);\nconsole.log(reduced, reduced === 1);\n","expected_output":"0.9999999999999999 false\n0.9999999999999999 false","verified_with":"Node.js v22.22.2"}],"fix":"Do the running total in integer minor units (cents) or with a Decimal type, not floating-point dollars. Don't rely on `sum()` happening to be more accurate than a loop in a particular Python version - that's an implementation detail, not a guarantee for every float computation.","related":["float-anchor-0-1-plus-0-2","naive-loop-sum-order-dependence"],"sources":[{"title":"Python docs: sum()","url":"https://docs.python.org/3/library/functions.html#sum"},{"title":"What's New In Python 3.12","url":"https://docs.python.org/3/whatsnew/3.12.html"},{"title":"Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations","url":"https://docs.python.org/3/tutorial/floatingpoint.html"}],"tags":["money","rounding","currency","accounting","price","invoice"],"test_cases":[{"inputs":[[0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1]],"relation":"no_crash","note":"A totaling function should not assume their_function(ten dimes) == 1.0 unless it works in integer cents or Decimal; don't trust that it happens to round correctly on today's runtime."}]},{"id":"tofixed-1-005-rounds-down-not-up","title":"(1.005).toFixed(2) is \"1.00\", not \"1.01\"","category":"float-money","assumption":"toFixed(2) rounds a price to the nearest cent using ordinary round-half-up rules.","input":{"display":"(1.005).toFixed(2)","description":"Rounding a computed price like $1.005 (e.g. a $1 item with a 0.5% fee) to two decimal places for display or for a ledger entry."},"what_goes_wrong":"`(1.005).toFixed(2)` returns the string \"1.00\" - the price appears to round down even though 1.005 is exactly halfway between 1.00 and 1.01.","why":"The literal 1.005 cannot be represented exactly in IEEE-754 double; the nearest representable double is approximately 1.00499999999999989341..., which is already below 1.005. `toFixed` rounds that true underlying value, not the digits you typed, so it correctly rounds 1.00499999... down to \"1.00\". The bug isn't in toFixed's rounding rule - it's that the number was never exactly 1.005 to begin with.","affects":["javascript","any IEEE-754 double formatted by rounding its true binary value"],"repros":[{"language":"javascript","status":"verified","code":"console.log((1.005).toFixed(2));\nconsole.log((1.005).toFixed(20));\n","expected_output":"1.00\n1.00499999999999989342","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"x = 1.005\nprint(f\"{x:.2f}\")\nprint(f\"{x:.20f}\")\n","expected_output":"1.00\n1.00499999999999989342","verified_with":"Python 3.14.2"}],"fix":"Don't round a float's decimal string representation and expect it to match the decimal literal you wrote. Work in integer minor units (round the amount to cents as an integer before any division back to dollars) or use a Decimal type constructed from a string, not a float literal.","related":["tofixed-2-345-inconsistent-rounding","python-round-2-675-float-imprecision","decimal-from-float-vs-string"],"sources":[{"title":"ECMA-262: Number.prototype.toFixed","url":"https://tc39.es/ecma262/#sec-number.prototype.tofixed"},{"title":"Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations","url":"https://docs.python.org/3/tutorial/floatingpoint.html"}],"tags":["money","rounding","price","currency","invoice","display"],"test_cases":[{"inputs":[1.005],"relation":"no_crash","note":"A price-rounding function should not assume their_function(1.005) == 1.01 by formatting the float's decimal digits; it should round an exact (integer-cents or Decimal) representation."}]},{"id":"tofixed-2-345-inconsistent-rounding","title":"2.345 rounds UP to 2.35 while 1.005 rounds DOWN to 1.00 - same \"x.xx5\" shape, opposite outcome","category":"float-money","assumption":"Any price ending in exactly half a cent (x.xx5) rounds the same way - either always up, or always down.","input":{"display":"(2.345).toFixed(2) vs (1.005).toFixed(2)","description":"Two different prices that both look like a clean half-cent boundary, rounded to cents for display."},"what_goes_wrong":"2.345 rounds to \"2.35\" (up) but 1.005 rounds to \"1.00\" (down), even though both are written with the same \"value.value5\" shape - there is no way to predict which direction a given half-cent literal will round without checking its actual binary value.","why":"Neither 2.345 nor 1.005 is exactly representable in IEEE-754 double. 2.345's nearest double happens to be a hair above 2.345 (2.34500000000000019540...), so it's not actually a tie and rounds up; 1.005's nearest double happens to be a hair below 1.005 (1.00499999999999989341...), so it rounds down. Both languages round the true stored binary value, not the decimal text you typed, and whether a given literal lands slightly above or below its decimal value is essentially unpredictable without inspecting the bits.","affects":["javascript","python","any IEEE-754 double formatted by rounding its true binary value"],"repros":[{"language":"javascript","status":"verified","code":"console.log((2.345).toFixed(2), (2.345).toFixed(20));\nconsole.log((1.005).toFixed(2), (1.005).toFixed(20));\n","expected_output":"2.35 2.34500000000000019540\n1.00 1.00499999999999989342","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"for x in (2.345, 1.005):\n print(f\"{x:.2f}\", f\"{x:.20f}\")\n","expected_output":"2.35 2.34500000000000019540\n1.00 1.00499999999999989342","verified_with":"Python 3.14.2"}],"fix":"Never treat float literals as exact decimal values for money. Round and store amounts as integer minor units, or construct a Decimal from the original decimal string (not from a float), so rounding operates on the number you actually meant.","related":["tofixed-1-005-rounds-down-not-up","python-round-2-675-float-imprecision"],"sources":[{"title":"ECMA-262: Number.prototype.toFixed","url":"https://tc39.es/ecma262/#sec-number.prototype.tofixed"},{"title":"Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations","url":"https://docs.python.org/3/tutorial/floatingpoint.html"}],"tags":["money","rounding","price","currency","invoice","display"],"test_cases":[{"inputs":["1.005"],"relation":"returns","expected":"1.01","note":"A money-rounding function that takes the exact decimal string \"1.005\" and rounds half-cents up should return \"1.01\" - if it instead parses to a float first and formats with toFixed, it will silently return \"1.00\"."}]},{"id":"array-index-above-2-32-2-not-an-index","title":"arr[4294967295] = x does not grow a JavaScript array's length, but arr[4294967294] = x does","category":"integers","assumption":"Any non-negative integer you assign as an array subscript is a real array index that updates .length.","input":{"display":"arr[2**32 - 2] = \"a\" updates .length to 2**32 - 1; arr[2**32 - 1] = \"b\" does NOT update .length at all","description":"Computing a very large array index programmatically (from a hash, an offset calculation, or a bug) and writing to it, expecting ordinary array growth semantics."},"what_goes_wrong":"Writing to what looks like 'one past the last valid huge index' silently creates an ordinary (non-index) property on the array object instead of extending the array, so `.length`, `for` loops bounded by `.length`, and array methods like `.forEach` never see that value at all.","why":"The ECMAScript spec defines an 'array index' as a string that is a canonical numeric representation of an integer in the range 0 to 2^32 - 2 inclusive -- 2^32 - 1 is deliberately excluded, because an array's `.length` is always an integer from 0 to 2^32 - 1 (setting it to 2^32 throws `RangeError`) and must be able to hold 'one past the largest index'. So the largest valid index is 2^32 - 2; assigning to 2^32 - 1 is legal (arrays allow arbitrary property keys) but does not count as an array index and therefore never triggers the `.length`-update behavior that real index assignment does.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"const arr = [];\narr[4294967294] = \"a\"; // 2**32 - 2, the largest valid array index\nconsole.log(\"after valid index:\", arr.length);\narr[4294967295] = \"b\"; // 2**32 - 1, NOT a valid array index\nconsole.log(\"after 2**32 - 1:\", arr.length);\nconsole.log(\"both keys present:\", (4294967294 in arr), (4294967295 in arr));\nconsole.log(\"own enumerable keys:\", Object.keys(arr).length);\n","expected_output":"after valid index: 4294967295\nafter 2**32 - 1: 4294967295\nboth keys present: true true\nown enumerable keys: 2","verified_with":"Node.js v22.22.2"}],"fix":"Never compute an array index without bounds-checking it against 2^32 - 2 first; if a value might legitimately be that large, use a Map or plain object keyed by string/number instead of relying on native array index semantics.","related":["max-safe-integer-plus-one-equals-plus-two"],"sources":[{"title":"ECMA-262: Array index definition","url":"https://tc39.es/ecma262/#array-index"},{"title":"ECMA-262: Array Exotic Objects (length invariants)","url":"https://tc39.es/ecma262/#sec-array-exotic-objects"}],"tags":["validation","database"],"test_cases":[]},{"id":"bigint-number-mixing-typeerror","title":"10n + 5 throws TypeError instead of just giving you 15","category":"integers","assumption":"Any two things that look like numbers can be added together directly.","input":{"display":"10n + 5","description":"Mixing a value that came from a BigInt-returning source (e.g. a large parsed id, `Number.MAX_SAFE_INTEGER` workaround, or a library that returns BigInt) with an ordinary numeric literal elsewhere in arithmetic."},"what_goes_wrong":"Introducing a single `BigInt` value into a codebase that otherwise uses plain `number` breaks at the first arithmetic operation that mixes the two, with a runtime `TypeError` rather than a silent or compile-time-caught issue.","why":"`BigInt` and `Number` are deliberately kept as separate, non-interoperable types in JavaScript: implicitly coercing one to the other would either lose a BigInt's exactness (converting to Number) or silently change comparison/arithmetic semantics, so the spec requires an explicit conversion (`BigInt(5)` or `Number(10n)`) before the two can be combined with `+`, `-`, `*`, etc. (Comparison operators like `<` and `==` are a deliberate exception and do work across the two types.)","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"try {\n console.log(10n + 5);\n} catch (e) {\n console.log(e.constructor.name + ':', e.message);\n}\nconsole.log(10n + BigInt(5));\nconsole.log(10n < 20); // comparison operators DO mix the types\n","expected_output":"TypeError: Cannot mix BigInt and other types, use explicit conversions\n15n\ntrue","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python has only one built-in integer type, so there is nothing to mix:\n# int already has unlimited precision, no separate 'big integer' type exists.\nprint(10 + 5, type(10 + 5))\n","expected_output":"15 <class 'int'>","verified_with":"Python 3.14.2"}],"fix":"Convert explicitly at the boundary where a value becomes a BigInt (or stays a Number) and keep the two types from mixing in arithmetic; don't let a single BigInt-returning call silently propagate into number-typed code.","related":["max-safe-integer-plus-one-equals-plus-two"],"sources":[{"title":"MDN: BigInt","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt"},{"title":"ECMA-262: BigInt operators","url":"https://tc39.es/ecma262/#sec-ecmascript-language-expressions"}],"tags":["id","parsing","validation"],"test_cases":[]},{"id":"bitwise-32-bit-truncation","title":"~~ and |0 quietly turn a large number negative","category":"integers","assumption":"~~x and x|0 are safe, fast shortcuts for 'truncate x to an integer' that work for any number.","input":{"display":"(2**31) | 0 === -2147483648; ~~(2**31) === -2147483648","description":"A common micro-optimization idiom (`~~x` or `x | 0` instead of `Math.trunc(x)` / `Math.floor(x)`) applied to a value that turns out to be at or above 2^31, such as a pixel coordinate, a large counter, or a computed array size."},"what_goes_wrong":"A value that should just be truncated toward zero (like `Math.trunc` would do) instead flips to a large negative number once it reaches 2^31, because the 'fast truncate' idiom is secretly doing 32-bit signed integer conversion, not generic truncation.","why":"The bitwise operators `~`, `|`, `&`, `^`, `<<` and `>>` first convert their (left) operands to a 32-bit signed integer (via ToInt32, which wraps modulo 2^32 and then reinterprets the top bit as the sign). `~~x` is just `~(~x)`, double bitwise-NOT, which forces this same 32-bit conversion twice and happens to cancel the bit-flip, leaving the ToInt32 result -- so both idioms silently wrap any value outside the 32-bit signed range [-2^31, 2^31 - 1] instead of just dropping its fractional part. The unsigned shift `>>>` uses ToUint32 instead, so `x >>> 0` wraps into [0, 2^32 - 1] rather than going negative (`-1 >>> 0` is 4294967295).","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"console.log((2 ** 31) | 0);\nconsole.log(~~(2 ** 31));\nconsole.log(Math.trunc(2 ** 31)); // the actually-safe way to truncate\nconsole.log((-5.7) | 0, ~~(-5.7)); // for in-range values, both idioms do look like truncation\n","expected_output":"-2147483648\n-2147483648\n2147483648\n-5 -5","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python has no implicit 32-bit integer conversion; ints are arbitrary precision\n# unless you explicitly wrap them (see the struct/ctypes exhibit).\nprint(2**31)\nprint(int(2**31)) # no truncation needed, no wraparound\n","expected_output":"2147483648\n2147483648","verified_with":"Python 3.14.2"}],"fix":"Use `Math.trunc()` (or `Math.floor()`/`Math.round()` as appropriate) to truncate a number, and reserve bitwise operators for values you know fit in 32 bits (e.g. actual bit flags, not general arithmetic results).","related":["python-unbounded-int-vs-fixed-width-wrap"],"sources":[{"title":"ECMA-262: ToInt32","url":"https://tc39.es/ecma262/#sec-toint32"},{"title":"MDN: Bitwise NOT (~)","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_NOT"}],"tags":["validation","parsing"],"test_cases":[{"inputs":[2147483648],"relation":"no_crash","note":"A 'truncate to integer' helper should not flip sign for inputs at or above 2^31; compare against Math.trunc."}]},{"id":"bool-is-int-subclass-dict-key-collision","title":"{1: \"a\", True: \"b\"} has only one key in Python, because True equals 1","category":"integers","assumption":"A boolean key and an integer key in a dictionary are always distinct entries, since booleans and integers are different types.","input":{"display":"{1: \"a\", True: \"b\"} -> {1: \"b\"}, a dict with exactly one entry","description":"Building a lookup table or counter dict keyed partly by integers and partly by booleans from mixed data (e.g. a CSV or JSON blob where some flag fields were coerced to 0/1 and others stayed as true/false)."},"what_goes_wrong":"Inserting entries keyed `1` and `True` (or `0` and `False`) into the same dict overwrites one with the other instead of creating two entries, silently losing data with no error.","why":"`bool` is a subclass of `int` in Python (`class bool(int)`), specifically so that `True` and `1` behave identically wherever an integer is expected. As a consequence, `True == 1` is `True`, and `hash(True) == hash(1)`, and Python dicts (and sets) de-duplicate keys by equality-and-hash together -- so `1` and `True` are, as far as a dict is concerned, the exact same key, and whichever value was assigned last wins.","affects":["python"],"repros":[{"language":"python","status":"verified","code":"print(isinstance(True, int), True == 1, hash(True) == hash(1))\nprint(True + True) # arithmetic coercion, unsurprising on its own\nd = {1: \"a\", True: \"b\"}\nprint(d, len(d))\n","expected_output":"True True True\n2\n{1: 'b'} 1","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// JS booleans are not a number subtype: object keys coerce to strings, and\n// Map uses SameValueZero (no type coercion), so 1 and true never collide.\nconst obj = { 1: \"a\", true: \"b\" };\nconsole.log(obj, Object.keys(obj));\nconst m = new Map([[1, \"a\"], [true, \"b\"]]);\nconsole.log(m.size, [...m.keys()]);\nconsole.log(true + true); // arithmetic coercion still matches Python here\n","expected_output":"{ '1': 'a', true: 'b' } [ '1', 'true' ]\n2 [ 1, true ]\n2","verified_with":"Node.js v22.22.2"}],"fix":"Never key a dict/set by a mix of booleans and small integers if you need them distinct; normalize flag values to a single consistent type (e.g. always `int`, or always `bool`) before using them as keys.","related":[],"sources":[{"title":"Python docs: bool -- Boolean Type","url":"https://docs.python.org/3/library/stdtypes.html#boolean-type-bool"},{"title":"Python docs: Built-in Types -- Mapping Types (dict)","url":"https://docs.python.org/3/library/stdtypes.html#mapping-types-dict"}],"tags":["database","validation","json"],"test_cases":[{"inputs":[[1,true]],"relation":"no_crash","note":"Any dict-building code keyed by mixed int/bool values should decide explicitly whether 1 and True are the same key."}]},{"id":"floor-div-mod-sign-python-vs-js","title":"-7 // 2 is -4 and -7 % 2 is 1 in Python, but Math.trunc(-7 / 2) is -3 and -7 % 2 is -1 in JavaScript","category":"integers","assumption":"Integer division of a negative number by a positive one rounds the same way in every language -- toward zero.","input":{"display":"Python: -7 // 2 == -4, -7 % 2 == 1. JavaScript: Math.trunc(-7 / 2) == -3, -7 % 2 == -1.","description":"Any pagination, bucketing, or hashing logic computed with integer division or modulo on a value that can be negative -- e.g. 'which page is item index i on' or 'which bucket does this signed offset fall into'."},"what_goes_wrong":"The same division expression applied to a negative dividend gives a different quotient AND a different-signed remainder depending on the language, so a bucketing or pagination formula ported between a Python backend and a JS frontend (or vice versa) silently puts negative-offset items in a different bucket on each side.","why":"Python's `//` is floor division: it rounds the mathematical quotient toward negative infinity, so -7 // 2 = -4 (since -3.5 floors to -4), and `%` is defined to satisfy `a == (a // b) * b + a % b`, which forces the remainder to take the sign of the divisor, giving -7 % 2 = 1. JavaScript's `/` followed by `Math.trunc` (or the legacy `~~`/`|0` truncation idioms) rounds toward zero instead, so -7/2 truncates to -3, and `%` in JS is a remainder operation (not a true modulo) that takes the sign of the DIVIDEND, giving -7 % 2 = -1. (JavaScript's `/` itself returns -3.5; `Math.floor(-7 / 2)` gives -4 and matches Python's `//`, so the quotient only diverges if you truncate. The `%` sign difference is built into the operator.)","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"print(-7 // 2, -7 % 2)\nprint(7 // -2, 7 % -2)\n","expected_output":"-4 1\n-4 -1","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"console.log(Math.trunc(-7 / 2), -7 % 2);\nconsole.log(Math.trunc(7 / -2), 7 % -2);\n// A true floor-mod, matching Python, has to be written out explicitly:\nfunction floorMod(a, b) { return ((a % b) + b) % b; }\nconsole.log(floorMod(-7, 2));\n","expected_output":"-3 -1\n-3 1\n1","verified_with":"Node.js v22.22.2"}],"fix":"Never port a division/modulo-based formula between Python and JavaScript without checking the sign convention explicitly; if you need Python's floor-mod behavior in JS, write it out as `((a % b) + b) % b` rather than relying on `%` alone.","related":[],"sources":[{"title":"Python docs: binary arithmetic operations (floor division)","url":"https://docs.python.org/3/reference/expressions.html#binary-arithmetic-operations"},{"title":"ECMA-262: Remainder operator semantics","url":"https://tc39.es/ecma262/#sec-numeric-types-number-remainder"}],"tags":["validation","pagination","database"],"test_cases":[]},{"id":"json-parse-loses-id-precision","title":"JSON.parse silently rounds a 64-bit id to the nearest value a JS number can hold","category":"integers","assumption":"Parsing a JSON number and then stringifying it again gives back the exact same digits you started with.","input":{"display":"JSON.parse(\"9007199254740993\") (a Twitter/Discord/Snowflake-style 64-bit id, one more than Number.MAX_SAFE_INTEGER)","description":"Any API that returns large integer ids as bare JSON numbers (not strings) -- Twitter/X status ids, Discord snowflakes, some database auto-increment ids -- consumed by a JavaScript client."},"what_goes_wrong":"An id of 9007199254740993 round-trips through `JSON.parse` as 9007199254740992 -- a different, smaller, EVEN number -- so looking that id up, comparing it, or sending it back to the server can silently hit the wrong record or none at all.","why":"The JSON specification defines numbers as an abstract decimal value with no size limit, but JavaScript's `JSON.parse` converts every JSON number into a 64-bit double, which (per IEEE 754) represents every integer exactly only up to 2^53; above that, neighbouring integers start sharing a double, which is why 2^53 - 1 is the largest 'safe' integer. 9007199254740993 (2^53 + 1) has no exact double representation; the nearest one is 2^53 (9007199254740992), and ties-to-even rounding picks that over 2^53 + 2. This is exactly why large-id APIs increasingly send such ids as JSON strings instead of bare numbers.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"const id = JSON.parse(\"9007199254740993\");\nconsole.log(id);\nconsole.log(id === 9007199254740992);\nconsole.log(JSON.stringify(id));\n","expected_output":"9007199254740992\ntrue\n9007199254740992","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"import json\n\n# Python's json module parses integers into arbitrary-precision int, so no\n# precision is lost here -- the gotcha is specific to a 64-bit-double parser.\nid_value = json.loads(\"9007199254740993\")\nprint(id_value, id_value == 9007199254740993)\n","expected_output":"9007199254740993 True","verified_with":"Python 3.14.2"}],"fix":"Never transmit an id that could exceed 2^53 - 1 as a bare JSON number; encode it as a JSON string on the wire, and parse/compare it as a string or BigInt on the JavaScript side, never as a plain `number`. If you cannot change the wire format, V8-based runtimes (Node 22, recent Chrome) pass the raw source text to a reviver as a third argument: `JSON.parse(text, (k, v, ctx) => typeof v === 'number' && !Number.isSafeInteger(v) ? BigInt(ctx.source) : v)`.","related":["max-safe-integer-plus-one-equals-plus-two"],"sources":[{"title":"RFC 8259: The JSON Data Interchange Format (numbers have no size limit)","url":"https://www.rfc-editor.org/rfc/rfc8259#section-6"},{"title":"MDN: JSON.parse","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse"}],"tags":["id","json","parsing","database"],"test_cases":[{"inputs":["9007199254740993"],"relation":"no_crash","note":"Parsing a large id from JSON must not silently change its value; compare as string or BigInt."}]},{"id":"max-safe-integer-plus-one-equals-plus-two","title":"Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2","category":"integers","assumption":"Adding 1 to a number and adding 2 to the same number always give different results.","input":{"display":"Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 -- both equal 9007199254740992","description":"An auto-incrementing counter, row id, or 'view count' stored as a plain JS number that has climbed past 2^53."},"what_goes_wrong":"A counter that increments a plain JS `number` past 2^53 - 1 stops being able to tell consecutive values apart: incrementing by 1 does nothing some of the time, so a counter can appear to stall or two different logical events can be recorded with the identical numeric id.","why":"JS numbers are IEEE 754 double-precision floats, which have 53 bits of mantissa and so they represent every integer exactly only up to 2^53. `Number.MAX_SAFE_INTEGER` is 2^53 - 1 because it is the largest n for which n and n + 1 are both exact. Between 2^53 and 2^54 the representable doubles are 2 apart, so `MAX_SAFE_INTEGER + 1` is exactly 2^53, and `MAX_SAFE_INTEGER + 2` (2^53 + 1) sits halfway between 2^53 and 2^53 + 2 and rounds, ties-to-even, down to 2^53 as well.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"console.log(Number.MAX_SAFE_INTEGER);\nconsole.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2);\nconsole.log(Number.MAX_SAFE_INTEGER + 1, Number.MAX_SAFE_INTEGER + 2);\nconsole.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER + 1));\n","expected_output":"9007199254740991\ntrue\n9007199254740992 9007199254740992\nfalse","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python ints are arbitrary precision, so the same arithmetic never collides.\nmax_safe = 2**53 - 1\nprint(max_safe + 1 == max_safe + 2)\nprint(max_safe + 1, max_safe + 2)\n","expected_output":"False\n9007199254740992 9007199254740993","verified_with":"Python 3.14.2"}],"fix":"Use `BigInt` (or a string) for any counter, id, or accumulated value that could exceed 2^53 - 1 in JavaScript; check `Number.isSafeInteger()` before trusting equality/ordering on a large computed number.","related":["json-parse-loses-id-precision"],"sources":[{"title":"MDN: Number.MAX_SAFE_INTEGER","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER"},{"title":"IEEE 754-2019 (floating-point arithmetic)","url":"https://ieeexplore.ieee.org/document/8766229"}],"tags":["id","counter","database","validation"],"test_cases":[{"inputs":[9007199254740991],"relation":"no_crash","note":"A safe-integer check should flag this value's successor as unsafe, not silently accept it."}]},{"id":"number-empty-and-whitespace-string-equals-zero","title":"Number(\"\") and Number(\" \") both equal 0","category":"integers","assumption":"An empty or blank string can't be converted to a valid number -- it should fail or return NaN.","input":{"display":"Number(\"\") === 0 and Number(\" \") === 0","description":"Converting a form field or query-string parameter that the user left blank, using `Number(value)` as a validation/conversion step."},"what_goes_wrong":"A blank form field, passed through `Number()` as part of 'sanitize to a number', becomes the perfectly valid number 0 instead of failing validation, so an unanswered quantity/price/age field silently records as zero rather than being rejected as missing.","why":"The ECMAScript string-to-number grammar defines the empty string (and a string containing only whitespace, since leading/trailing whitespace is trimmed first) as a valid `StringNumericLiteral` whose value is 0 -- this is an explicit rule in the spec, not an accidental byproduct of trimming. `parseInt` and `parseFloat`, by contrast, both return `NaN` for the same inputs, because they don't follow this grammar rule.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"console.log(Number(\"\"), Number(\" \"), Number(\"\\t\\n\"));\nconsole.log(parseInt(\"\"), parseFloat(\" \"));\n","expected_output":"0 0 0\nNaN NaN","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python's numeric constructors raise on an empty or blank string rather than defaulting to 0.\nfor s in (\"\", \" \"):\n try:\n int(s)\n except ValueError as e:\n print(repr(s), \"-> ValueError:\", e)\n","expected_output":"'' -> ValueError: invalid literal for int() with base 10: ''\n' ' -> ValueError: invalid literal for int() with base 10: ' '","verified_with":"Python 3.14.2"}],"fix":"Check for an empty/whitespace-only string explicitly before calling `Number()` on user input; don't rely on it to reject blanks, since by spec it treats them as a valid zero.","related":[],"sources":[{"title":"ECMA-262: ToNumber applied to the String type (StringNumericLiteral)","url":"https://tc39.es/ecma262/#sec-tonumber-applied-to-the-string-type"},{"title":"MDN: Number()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number"}],"tags":["parsing","validation","forms"],"test_cases":[{"inputs":[""],"relation":"no_crash","note":"A numeric-field validator must treat an empty string as missing, not silently accept it as zero."}]},{"id":"parseint-null-radix36-digit-soup","title":"parseInt(null, 36) returns 1112745","category":"integers","assumption":"Passing null where a string is expected is harmless, or at worst gives you 0 or NaN.","input":{"display":"parseInt(null, 36) === 1112745","description":"A missing or unset form field, config value, or optional function argument that falls through to `parseInt` without a guard, combined with a non-default radix such as base36 (used for compact id encoding)."},"what_goes_wrong":"A missing value that should produce 0, NaN, or an error instead silently produces a real-looking, nonzero integer -- 1112745 -- which can pass a `typeof x === 'number' && !isNaN(x)` guard and flow deep into the system before anyone notices the input was never actually a number.","why":"`parseInt`'s first argument is coerced to a string before parsing, and `String(null)` is the literal string `\"null\"`. In base 36, digits run 0-9 then a-z representing 10-35, so every character of \"null\" (n, u, l, l) is a valid base-36 digit, and the whole string parses successfully as a number -- specifically 1112745. Nothing about this path raises an error or returns NaN, because from `parseInt`'s point of view it was simply handed a valid base-36 numeral.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"console.log(String(null));\nconsole.log(parseInt(null, 36));\nconsole.log(parseInt(\"null\", 36));\nconsole.log(parseInt(undefined, 36)); // String(undefined) === 'undefined', also all valid base-36 digits\n","expected_output":"null\n1112745\n1112745\n86464843759093","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python's int() with an explicit base requires an actual string and rejects None outright.\ntry:\n int(None, 36)\nexcept TypeError as e:\n print(\"TypeError:\", e)\n","expected_output":"TypeError: int() can't convert non-string with explicit base","verified_with":"Python 3.14.2"}],"fix":"Guard against `null`/`undefined` before calling `parseInt` rather than relying on it to fail loudly; it won't, for any radix where the stringified sentinel's letters happen to be valid digits.","related":["parseint-stops-at-first-invalid-char-vs-number"],"sources":[{"title":"MDN: parseInt()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseInt"},{"title":"ECMA-262: parseInt (string coercion of the first argument)","url":"https://tc39.es/ecma262/#sec-parseint-string-radix"}],"tags":["parsing","validation","forms"],"test_cases":[{"inputs":[null],"relation":"no_crash","note":"A function wrapping parseInt with a non-10 radix should explicitly reject null/undefined rather than let String() coercion through."}]},{"id":"parseint-number-argument-scientific-notation","title":"parseInt(0.0000005) returns 5, not 0","category":"integers","assumption":"parseInt() reads the digits of the number you gave it, in the order you wrote them.","input":{"display":"parseInt(0.0000005) -- note: the NUMBER 0.0000005, not the string \"0.0000005\"","description":"Calling parseInt on a small decimal number directly (rather than on a string), e.g. while sanitizing a computed fractional value into an integer."},"what_goes_wrong":"`parseInt(0.0000005)` returns 5 -- not 0, and not an error -- because the number gets stringified to scientific notation before parseInt ever sees a digit of the original decimal.","why":"`parseInt` only accepts a string; when given a `number`, JavaScript first coerces it to a string via the Number-to-String algorithm. That algorithm switches to exponent notation for any magnitude below 1e-6 (and at or above 1e21), so 0.000001 stringifies as \"0.000001\" but 0.0000005 becomes \"5e-7\", not \"0.0000005\". `parseInt` then reads the string from the start, parsing digits until it hits the first character that isn't part of an integer -- which is \"e\" -- so it returns 5, the only digit it saw before stopping. Calling `parseInt(\"0.0000005\")` on the literal string instead returns 0, because the string parse stops at the \".\". The large end bites the same way: `parseInt(1e21)` is 1, because 1e21 stringifies as \"1e+21\".","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"console.log((0.0000005).toString()); // what parseInt actually sees\nconsole.log(parseInt(0.0000005)); // number argument -> stringified first\nconsole.log(parseInt(\"0.0000005\")); // string argument -> no scientific notation\n","expected_output":"5e-7\n5\n0","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python's int() refuses a float string outright rather than guessing a prefix.\ntry:\n int(\"0.0000005\")\nexcept ValueError as e:\n print(\"ValueError:\", e)\nprint(int(0.0000005)) # int() of an actual float truncates toward zero instead\n","expected_output":"ValueError: invalid literal for int() with base 10: '0.0000005'\n0","verified_with":"Python 3.14.2"}],"fix":"Never pass a `number` directly to `parseInt`; convert deliberately with `Math.trunc()`/`Math.floor()` for numbers, and reserve `parseInt` for strings whose format you've already validated.","related":["parseint-stops-at-first-invalid-char-vs-number"],"sources":[{"title":"MDN: parseInt()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseInt"},{"title":"ECMA-262: Number::toString","url":"https://tc39.es/ecma262/#sec-numeric-types-number-tostring"}],"tags":["parsing","validation"],"test_cases":[]},{"id":"parseint-stops-at-first-invalid-char-vs-number","title":"parseInt(\"1e3\") is 1, but Number(\"1e3\") is 1000","category":"integers","assumption":"parseInt and Number agree on what a numeric string means; parseInt is just 'the integer version'.","input":{"display":"parseInt(\"1e3\") === 1 while Number(\"1e3\") === 1000","description":"Converting a user-entered or CSV-sourced numeric string that happens to be in scientific notation (common in spreadsheet exports of large or small numbers)."},"what_goes_wrong":"Switching a 'parse this numeric string' call from `Number()` to `parseInt()` (e.g. 'because I want an integer') silently truncates any value written in scientific notation to just its leading digit, turning 1000 into 1 with no error.","why":"`parseInt` parses a string by reading characters strictly left to right and stopping at the first character that cannot extend a valid integer literal in the given radix -- it has no concept of an exponent, so it stops at the \"e\". `Number()` instead parses the whole string against ECMA-262's StringNumericLiteral grammar (similar to, but not the same as, source-code numeric literals: it allows surrounding whitespace and \"Infinity\", and rejects numeric separators and the `n` BigInt suffix), which does understand exponents, so it parses \"1e3\" as 1 * 10^3 = 1000. The two are not interchangeable string-to-number converters.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"console.log(parseInt(\"1e3\"), Number(\"1e3\"));\nconsole.log(parseInt(\"3.14\"), Number(\"3.14\"));\nconsole.log(parseInt(\"1,000\"), Number(\"1,000\"));\n","expected_output":"1 1000\n3 3.14\n1 NaN","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"# Python's int() rejects scientific notation strings outright rather than truncating.\ntry:\n int(\"1e3\")\nexcept ValueError as e:\n print(\"int('1e3') ValueError:\", e)\nprint(float(\"1e3\")) # the float constructor does understand exponents\n","expected_output":"int('1e3') ValueError: invalid literal for int() with base 10: '1e3'\n1000.0","verified_with":"Python 3.14.2"}],"fix":"Use `Number()` (and then validate/round) to parse a numeric string that might contain an exponent, decimals, or thousands separators; reserve `parseInt` for strings you know are plain digit sequences in a known radix.","related":["parseint-number-argument-scientific-notation","parseint-null-radix36-digit-soup"],"sources":[{"title":"MDN: parseInt()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseInt"},{"title":"ECMA-262: ToNumber applied to the String type","url":"https://tc39.es/ecma262/#sec-tonumber-applied-to-the-string-type"}],"tags":["parsing","validation","csv"],"test_cases":[{"inputs":["1e3"],"relation":"no_crash","note":"Decide explicitly whether a numeric-string parser should understand scientific notation; don't silently truncate at the exponent."}]},{"id":"python-int-accepts-fullwidth-unicode-digits","title":"int(\"42\") works -- those are fullwidth Unicode digits, not the ASCII characters they look like","category":"integers","assumption":"int() only accepts the ASCII digits 0-9.","input":{"display":"int(\"\\uff14\\uff12\") == 42 (U+FF14 FULLWIDTH DIGIT FOUR, U+FF12 FULLWIDTH DIGIT TWO)","description":"User input copy-pasted from a source using fullwidth characters (common in East Asian text contexts, or a sneaky/naughty-string test input), or any other Unicode script's decimal digits (Arabic-Indic, Devanagari, etc.) submitted to a form field."},"what_goes_wrong":"A validator that checks `s.isdigit()` or `re.fullmatch(r'\\d+', s)` and then calls `int(s)` happily accepts fullwidth, Arabic-Indic, Devanagari and other Unicode digits (both checks are Unicode-aware for str), while a check anchored to `[0-9]` rejects the same input that `int()` would accept -- so two layers of the same form can disagree about what a number is.","why":"Python's `int()` (and `float()`) accepts any character with the Unicode general category Nd ('decimal digit'), which covers fullwidth forms (U+FF10-FF19), Arabic-Indic, Devanagari and dozens of other scripts' 0-9, and maps each to its decimal value; it also strips any Unicode whitespace around the number. This is documented, deliberate internationalisation in the string-to-number conversion, not in the language's source parser: `x = 42` in source code is a SyntaxError. Python's regex `\\d` is likewise Unicode-aware for str patterns unless you pass `re.ASCII`. And the sets do not line up: `'²'.isdigit()` is True for superscript two, but it is not Nd, so `int('²')` raises.","affects":["python"],"repros":[{"language":"python","status":"verified","code":"import re\nfullwidth = \"\\uff14\\uff12\" # looks like \"42\"\nprint(repr(fullwidth), \"->\", int(fullwidth))\nprint(\"matches [0-9]+:\", bool(re.fullmatch(r\"[0-9]+\", fullwidth)))\nprint(\"matches \\\\d+:\", bool(re.fullmatch(r\"\\d+\", fullwidth)), \"| with re.ASCII:\", bool(re.fullmatch(r\"\\d+\", fullwidth, re.ASCII)))\nsup = \"\\u00b2\" # SUPERSCRIPT TWO\ntry:\n int(sup)\nexcept ValueError as e:\n print(repr(sup), \"isdigit():\", sup.isdigit(), \"| int() ->\", e)","expected_output":"'42' -> 42\nmatches [0-9]+: False\nmatches \\d+: True | with re.ASCII: False\n'²' isdigit(): True | int() -> invalid literal for int() with base 10: '²'","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// Number() and parseInt() do NOT extend digit recognition past ASCII 0-9,\n// so the same fullwidth string fails to parse at all in JavaScript.\nconst fullwidth = \"\\uff14\\uff12\";\nconsole.log(Number(fullwidth), parseInt(fullwidth, 10));\n","expected_output":"NaN NaN","verified_with":"Node.js v22.22.2"}],"fix":"If you need ASCII digits only, say so explicitly: `s.isascii() and s.isdigit()`, `re.fullmatch(r'[0-9]+', s)` or `re.fullmatch(r'\\d+', s, re.ASCII)`, and run `int()` on the same string you validated. If you want to accept other scripts' digits, normalise them deliberately (e.g. `str(int(s))`) before storing or passing the value to systems that only understand ASCII.","related":["python-int-accepts-underscores-and-whitespace"],"sources":[{"title":"Unicode Standard: General Category Values (Nd = Decimal Number)","url":"https://www.unicode.org/reports/tr44/#General_Category_Values"},{"title":"Python docs: int() constructor","url":"https://docs.python.org/3/library/functions.html#int"},{"title":"Python docs: numeric types (int/float accept digits with the Unicode Nd property)","url":"https://docs.python.org/3/library/stdtypes.html#numeric-types-int-float-complex"}],"tags":["parsing","validation","i18n","forms","normalization","security"],"test_cases":[]},{"id":"python-int-accepts-underscores-and-whitespace","title":"int(\" 42 \") and int(\"1_000\") both just work","category":"integers","assumption":"int() only accepts exactly what you could type as an integer literal in source code, with nothing extra around it.","input":{"display":"int(\" 42 \") == 42; int(\"1_000\") == 1000","description":"Converting a number pulled from a config file, environment variable, or user-pasted value that has incidental surrounding whitespace, or a number formatted with underscore thousands-separators (a format Python itself started allowing in source literals in 3.6)."},"what_goes_wrong":"A validation routine that assumes `int()` will reject anything that isn't a bare run of digits lets through both leading/trailing whitespace and underscore-separated digit groups, silently accepting input shapes the developer never intended to allow.","why":"`int()`'s string-to-integer conversion strips leading and trailing whitespace before parsing, by design, and that means any Unicode whitespace (tabs, newlines, U+3000 IDEOGRAPHIC SPACE), not just spaces. Separately, since Python 3.6 (PEP 515), the integer literal grammar allows a single underscore between digits as a visual separator (`1_000`), and `int()`'s string parsing follows that same grammar, so it accepts underscore-separated digit strings too -- as long as the underscores are singly placed between digits, not leading, trailing, or doubled.","affects":["python"],"repros":[{"language":"python","status":"verified","code":"print(int(\" 42 \"))\nprint(int(\"1_000\"))\ntry:\n int(\"_1000\")\nexcept ValueError as e:\n print(\"leading underscore rejected:\", e)\n","expected_output":"42\n1000\nleading underscore rejected: invalid literal for int() with base 10: '_1000'","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// Number() also trims surrounding whitespace. JavaScript's numeric separators\n// (1_000, ES2021, the same single-underscore-between-digits rule as Python)\n// exist only in source-code literals, never in string parsing.\nconsole.log(Number(\" 42 \")); // 42: whitespace is trimmed here too\nconsole.log(Number(\"1_000\")); // NaN: Number() does not accept separators in strings\nconsole.log(parseInt(\"1_000\")); // 1: parseInt stops at the underscore\nconsole.log(1_000); // 1000: but the literal is fine","expected_output":"42\nNaN\n1\n1000","verified_with":"Node.js v22.22.2"}],"fix":"Don't assume `int()` validates format as strictly as a literal; if you need to reject whitespace or separators, check the string's shape explicitly (e.g. a regex) before calling `int()`.","related":["python-int-accepts-fullwidth-unicode-digits"],"sources":[{"title":"Python docs: int() constructor","url":"https://docs.python.org/3/library/functions.html#int"},{"title":"PEP 515 -- Underscores in Numeric Literals","url":"https://peps.python.org/pep-0515/"}],"tags":["parsing","validation"],"test_cases":[]},{"id":"python-unbounded-int-vs-fixed-width-wrap","title":"Python's int never overflows -- until you explicitly ask for a fixed-width one","category":"integers","assumption":"An integer type has some maximum size, and adding to it eventually overflows or errors.","input":{"display":"struct.pack(\"i\", 2**31) raises; ctypes.c_int32(2**31).value wraps to -2147483648","description":"Serializing a Python int into a fixed-width binary format (a network protocol, a C-compatible struct, a file format) where the field is a 32-bit signed integer, after the value has grown past what that field can hold."},"what_goes_wrong":"Code that works fine for years because Python's `int` just keeps growing starts failing (or, worse, silently wraps to a negative number) the moment that same value has to be packed into a fixed-width field for serialization, networking, or C interop -- and which of those two happens depends entirely on which API you used to do the packing.","why":"Python's built-in `int` is arbitrary-precision: it has no fixed bit width and never overflows on its own. Fixed-width behavior only appears when you explicitly convert to one -- `struct.pack` with a format like `\"i\"` (the platform's C `int`, signed 32-bit on all mainstream platforms) validates the range and raises `struct.error` if the value doesn't fit, while `ctypes.c_int32` instead performs a real two's-complement modulo-2^32 wraparound, silently producing a negative value with no error at all. The two standard-library tools for 'give this a fixed width' disagree on whether out-of-range is an error or a wraparound.","affects":["python"],"repros":[{"language":"python","status":"verified","code":"import struct\nimport ctypes\n\ntry:\n struct.pack(\"i\", 2**31)\nexcept struct.error as e:\n print(\"struct.pack raises:\", e)\n\nprint(\"ctypes.c_int32 wraps silently:\", ctypes.c_int32(2**31).value)\nprint(\"plain python int, unaffected:\", 2**31)\n","expected_output":"struct.pack raises: 'i' format requires -2147483648 <= number <= 2147483647\nctypes.c_int32 wraps silently: -2147483648\nplain python int, unaffected: 2147483648","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"// JS has no arbitrary-precision integer type by default (Number is a double),\n// but a typed array shows the same silent-wrap behavior as ctypes.c_int32.\nconst a = new Int32Array(1);\na[0] = 2 ** 31;\nconsole.log(\"Int32Array wraps silently:\", a[0]);\n","expected_output":"Int32Array wraps silently: -2147483648","verified_with":"Node.js v22.22.2"}],"fix":"When serializing a Python int into a fixed-width field, validate the range yourself first (or use `struct.pack` and handle the `struct.error`) rather than reaching for `ctypes`, whose wraparound is silent and easy to miss in a code review.","related":["bitwise-32-bit-truncation"],"sources":[{"title":"Python docs: struct -- Format Characters","url":"https://docs.python.org/3/library/struct.html#format-characters"},{"title":"Python docs: ctypes -- Fundamental data types","url":"https://docs.python.org/3/library/ctypes.html#fundamental-data-types"}],"tags":["validation","parsing","database"],"test_cases":[]},{"id":"address-with-no-street-number","title":"A rural route, a PO box, and a named lot all have an address with no street number at all","category":"names-addresses","assumption":"Every valid street address begins with a number.","input":{"display":"\"Rural Route 2\", \"PO Box 445\", \"Lot 7 Orchard Lane\"","description":"An address-line validator that requires the first token to be numeric, used on a shipping or billing form."},"what_goes_wrong":"Entirely legitimate, deliverable addresses are rejected because they don't start with digits — rural routes, PO boxes, and named-lot rural properties are all real addressing conventions with no house number.","why":"A leading street number is just one of several addressing schemes in common use; postal systems also deliver to rural route numbers, post office boxes, and named lots or stations that never had a house number assigned at all. A regex that hard-requires a leading digit encodes 'suburban street address' as if it were the only valid addressing format, when it is only one of several.","affects":["any address validator requiring a leading street number"],"repros":[{"language":"python","status":"verified","code":"import re\npattern = re.compile(r\"^\\d+\\s\")\nfor addr in [\"Rural Route 2\", \"PO Box 445\", \"Lot 7 Orchard Lane\"]:\n print(addr, bool(pattern.match(addr)))\n","expected_output":"Rural Route 2 False\nPO Box 445 False\nLot 7 Orchard Lane False","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const pattern = /^\\d+\\s/;\nfor (const addr of [\"Rural Route 2\", \"PO Box 445\", \"Lot 7 Orchard Lane\"]) {\n console.log(addr, pattern.test(addr));\n}\n","expected_output":"Rural Route 2 false\nPO Box 445 false\nLot 7 Orchard Lane false","verified_with":"Node.js v22.22.2"}],"fix":"Don't require an address to start with a number; if you need structured address fields, make the house-number field optional and let 'address line 1' accept free text, which is how postal services themselves treat it.","related":["house-number-suffix-collapsed-by-digit-extraction"],"sources":[{"title":"Michael Tandy: Falsehoods Programmers Believe About Addresses","url":"https://www.mjt.me.uk/posts/falsehoods-programmers-believe-about-addresses/"}],"tags":["addresses","validation","forms","parsing"],"test_cases":[{"inputs":["PO Box 445"],"relation":"no_crash","note":"A PO Box is a deliverable address with no street number; your form should accept it."}]},{"id":"ascii-regex-rejects-accented-and-hyphenated-names","title":"[A-Za-z ]+ rejects \"Anne-Marie\", \"O'Brien\", and anyone with an accent in their name","category":"names-addresses","assumption":"A simple letters-and-spaces regex is a reasonable baseline check for 'is this a real name'.","input":{"display":"\"Zo\\u00eb Navarro-Pe\\u00f1a\" (invented), \"Anne-Marie\", \"O'Brien\"","description":"A 'name must contain only letters' validator on a signup or checkout form, written against the ASCII alphabet."},"what_goes_wrong":"Entirely ordinary names are rejected outright: an accented letter, a hyphen joining two given names, or an apostrophe in a surname all fail a check that was only ever tested against plain ASCII names.","why":"The character class [A-Za-z] matches only the 26 unaccented Latin letters in each case; it has no entries for accented Latin letters, hyphens, apostrophes, or any non-Latin script at all. Any name that uses punctuation that is a normal part of that name, or any letter outside the ASCII range, fails the match even though the input is a perfectly ordinary name.","affects":["any regex validator built on [A-Za-z] or similar ascii-only classes"],"repros":[{"language":"python","status":"verified","code":"import re\npattern = re.compile(r\"^[A-Za-z ]+$\")\nfor name in [\"Zo\\u00eb Navarro-Pe\\u00f1a\", \"Anne-Marie\", \"O'Brien\", \"Jose Luis\"]:\n print(name, bool(pattern.match(name)))\n","expected_output":"Zoë Navarro-Peña False\nAnne-Marie False\nO'Brien False\nJose Luis True","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const pattern = /^[A-Za-z ]+$/;\nfor (const name of [\"Zo\\u00eb Navarro-Pe\\u00f1a\", \"Anne-Marie\", \"O'Brien\", \"Jose Luis\"]) {\n console.log(name, pattern.test(name));\n}\n","expected_output":"Zoë Navarro-Peña false\nAnne-Marie false\nO'Brien false\nJose Luis true","verified_with":"Node.js v22.22.2"}],"fix":"Don't validate names against a character allowlist at all if you can avoid it; if you must reject something, reject on length or control characters, not on script or punctuation — a name field should accept Unicode letters, marks, hyphens, and apostrophes at minimum.","related":["title-case-mangles-mixed-case-and-apostrophes"],"sources":[{"title":"Patrick McKenzie: Falsehoods Programmers Believe About Names","url":"https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/"}],"tags":["names","validation","forms","signup","i18n"],"test_cases":[{"inputs":["Zoë Navarro-Peña"],"relation":"no_crash","note":"Your name validator should accept this; if it's built on an ASCII letter class, it won't."}]},{"id":"house-number-suffix-collapsed-by-digit-extraction","title":"\"221B Baker Street\" and \"221 Baker Street\" extract to the same house number","category":"names-addresses","assumption":"Pulling the leading digits off an address line gives you a unique house-number key.","input":{"display":"\"221B Baker Street\" vs \"221 Baker Street\"; \"12\\u00bd Main Street\" vs \"12 Main Street\"","description":"A geocoding or dedup step that extracts 'the house number' from an address line with a leading-digits regex, to compare or key addresses."},"what_goes_wrong":"Two genuinely different addresses — a subdivided unit \"221B\" and the plain \"221\" — collapse to the identical extracted number \"221\", silently merging two different physical locations in any system that keys on the extracted value.","why":"A leading-digit regex like ^(\\d+) stops at the first non-digit character, so it captures only \"221\" and discards the trailing letter suffix \"B\" — which is not decoration: it typically marks a separate property created by subdividing or infilling, and is what distinguishes it from plain 221. The same problem affects fractional house numbers like \"12½\", where the regex captures \"12\" and silently drops the ½ that distinguishes it from plain \"12\".","affects":["any 'extract leading digits as the house number' parser"],"repros":[{"language":"python","status":"verified","code":"import re\ndef house_number(addr):\n m = re.match(r\"^(\\d+)\", addr)\n return m.group(1) if m else None\n\nfor a in [\"221B Baker Street\", \"221 Baker Street\", \"12\\u00bd Main Street\", \"12 Main Street\"]:\n print(a, \"->\", house_number(a))\n","expected_output":"221B Baker Street -> 221\n221 Baker Street -> 221\n12½ Main Street -> 12\n12 Main Street -> 12","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function houseNumber(addr) {\n const m = addr.match(/^(\\d+)/);\n return m ? m[1] : null;\n}\nfor (const a of [\"221B Baker Street\", \"221 Baker Street\", \"12\\u00bd Main Street\", \"12 Main Street\"]) {\n console.log(a, \"->\", houseNumber(a));\n}\n","expected_output":"221B Baker Street -> 221\n221 Baker Street -> 221\n12½ Main Street -> 12\n12 Main Street -> 12","verified_with":"Node.js v22.22.2"}],"fix":"Keep the full house-number token, including any letter suffix or fraction, as the key, instead of truncating to the leading digits; treat \"221\" and \"221B\" as different addresses unless you've confirmed otherwise.","related":["address-with-no-street-number"],"sources":[{"title":"Michael Tandy: Falsehoods Programmers Believe About Addresses","url":"https://www.mjt.me.uk/posts/falsehoods-programmers-believe-about-addresses/"}],"tags":["addresses","parsing","validation","database"],"test_cases":[{"inputs":["221B Baker Street","221 Baker Street"],"relation":"not_equal","note":"These are two different addresses; your house-number extraction should not treat them as the same key."}]},{"id":"mononym-fails-first-last-required","title":"A one-word name fails 'first and last name required' — because it IS one word, correctly","category":"names-addresses","assumption":"Every person has both a first name and a last name to put in two separate required fields.","input":{"display":"\"Wulandari\" (an invented single-word legal name, no space)","description":"A signup, shipping, or ID-verification form with separate required 'First Name' and 'Last Name' fields, applied to someone who legally has one name only."},"what_goes_wrong":"The validator rejects the whole name as incomplete, forcing the person to invent a fake last name, or re-type their one name into both boxes, just to get past the form.","why":"Single-word names are a real, legally recognized naming convention: many Indonesians are registered with one name only, and the pattern occurs in other cultures too. A validator that REQUIRES a 'last' component after splitting on whitespace will always reject a single-word name, because there is nothing to split; the assumption that a name has two parts is false for this input, not a formatting mistake to be corrected.","affects":["any 'first + last required' form validator"],"repros":[{"language":"python","status":"verified","code":"def validate_two_part_name(name):\n parts = name.split(\" \")\n if len(parts) < 2:\n raise ValueError(\"first and last name are both required\")\n return True\n\ntry:\n validate_two_part_name(\"Wulandari\")\nexcept ValueError as e:\n print(\"rejected:\", e)\n","expected_output":"rejected: first and last name are both required","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function validateTwoPartName(name) {\n const parts = name.split(\" \");\n if (parts.length < 2) throw new Error(\"first and last name are both required\");\n return true;\n}\ntry {\n validateTwoPartName(\"Wulandari\");\n} catch (e) {\n console.log(\"rejected:\", e.message);\n}\n","expected_output":"rejected: first and last name are both required","verified_with":"Node.js v22.22.2"}],"fix":"Collect a single 'full name' field (or make the family-name field explicitly optional) instead of hard-requiring two parts; let the person tell you how their name is structured rather than inferring it from word count.","related":["split-surname-on-last-space-loses-particle","single-letter-surname-fails-min-length"],"sources":[{"title":"Patrick McKenzie: Falsehoods Programmers Believe About Names","url":"https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/"}],"tags":["names","forms","validation","signup","i18n"],"test_cases":[{"inputs":["Wulandari"],"relation":"no_crash","note":"Your signup form should accept a single-word legal name without forcing a fake second word."}]},{"id":"naive-lowercase-breaks-greek-final-sigma","title":"Build your own per-letter lowercase table and Greek \"ΟΔΥΣΣΕΥΣ\" lowercases to the wrong letter at the end","category":"names-addresses","assumption":"Lowercasing a string is just mapping each character to its lowercase equivalent, independent of its neighbors.","input":{"display":"\"ΟΔΥΣΣΕΥΣ\" (Greek, 'ODYSSEUS')","description":"A hand-rolled case-folding table (common in custom search normalizers, or a simple transliteration helper) that maps each character independently, rather than calling the language's real lowercasing routine."},"what_goes_wrong":"A search-index normalizer built this way produces a lowercase form that disagrees with the one str.lower() / toLowerCase() produces, so a lookup built against one and compared against the other silently fails to match at the end of every Greek word ending in capital sigma.","why":"Greek has two lowercase forms of sigma: σ in the middle of a word and ς (final sigma) at the end of a word — which form is correct depends on the character's POSITION in the word, not just its identity. Unicode's SpecialCasing rules encode that context-sensitive mapping, and both Python's str.lower() and JavaScript's toLowerCase() correctly apply it. A naive table that maps Σ to σ unconditionally, character by character with no notion of 'end of word', can never produce the final form, so it disagrees with the correct, built-in result precisely where a word ends in sigma.","affects":["any hand-rolled per-character case table (no word-context)","python str.lower (correctly context-sensitive)","javascript toLowerCase (correctly context-sensitive)"],"repros":[{"language":"python","status":"verified","code":"naive_map = {\"\\u03a3\": \"\\u03c3\"}\ndef naive_lower(s):\n return \"\".join(naive_map.get(c, c.lower()) for c in s)\n\nword = \"\\u039f\\u0394\\u03a5\\u03a3\\u03a3\\u0395\\u03a5\\u03a3\"\nprint(naive_lower(word) == word.lower())\nprint(naive_lower(word))\nprint(word.lower())\n","expected_output":"False\nοδυσσευσ\nοδυσσευς","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const naiveMap = { \"\\u03a3\": \"\\u03c3\" };\nfunction naiveLower(s) {\n return [...s].map((c) => naiveMap[c] ?? c.toLowerCase()).join(\"\");\n}\nconst word = \"\\u039f\\u0394\\u03a5\\u03a3\\u03a3\\u0395\\u03a5\\u03a3\";\nconsole.log(naiveLower(word) === word.toLowerCase());\nconsole.log(naiveLower(word));\nconsole.log(word.toLowerCase());\n","expected_output":"false\nοδυσσευσ\nοδυσσευς","verified_with":"Node.js v22.22.2"}],"fix":"Never build a custom per-character case-folding table for a script you haven't checked for context-sensitive rules; use the language's built-in Unicode-aware lower()/toLowerCase() (or ICU) and only post-process it if you have a specific, tested reason to.","related":[],"sources":[{"title":"Unicode SpecialCasing.txt (Final_Sigma condition)","url":"https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt"},{"title":"Python docs: str.lower()","url":"https://docs.python.org/3/library/stdtypes.html#str.lower"},{"title":"ECMAScript spec: String.prototype.toLowerCase","url":"https://tc39.es/ecma262/#sec-string.prototype.tolowercase"}],"tags":["names","i18n","normalization","search","database"],"test_cases":[{"inputs":["ΟΔΥΣΣΕΥΣ"],"relation":"returns","expected":"οδυσσευς","note":"If you built your own per-character lowercase table, verify it against this: the correct result ends in final sigma (ς), not plain sigma (σ)."}]},{"id":"null-surname-coerced-to-null-value","title":"A surname that is literally the word \"Null\" gets cleaned away to an actual null","category":"names-addresses","assumption":"A string-cleaning step that treats the text 'null' as a sign of missing data is harmless.","input":{"display":"\"Null\" (a real surname)","description":"A 'sanitize this field' helper applied to a last-name input, written to catch forms that leak the literal word null/NULL as placeholder text for an empty field."},"what_goes_wrong":"A person whose actual surname is Null has their name replaced with an actual missing value, because the cleaning function cannot tell the difference between the placeholder string and the real data.","why":"Some input pipelines add a defensive check (if the trimmed, lowercased field equals the text \"null\", treat it as empty) to guard against a template or export rendering the literal word when a field is unset. That check is a string comparison with no awareness that \"Null\" is also a real, if uncommon, surname, so it deletes a correct answer precisely because it looks like the sentinel. People with that surname have publicly described forms and systems rejecting their name or treating it as missing.","affects":["any 'treat the string null as empty' sanitizer"],"repros":[{"language":"python","status":"verified","code":"def clean_field(name):\n if not name or name.strip().lower() == \"null\":\n return None\n return name.strip()\n\nprint(clean_field(\"Null\"))\nprint(clean_field(\"O'Null\"))\n","expected_output":"None\nO'Null","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function cleanField(name) {\n if (!name || name.toLowerCase() === \"null\") return null;\n return name;\n}\nconsole.log(cleanField(\"Null\"));\nconsole.log(cleanField(\"O'Null\"));\n","expected_output":"null\nO'Null","verified_with":"Node.js v22.22.2"}],"fix":"Never infer 'this field is empty' from its text content matching a sentinel word; use an explicit null/empty marker from the data layer (an actual null, not the string \"null\") so a real value can never collide with it.","related":[],"sources":[{"title":"WIRED (2015): Hello, I'm Mr. Null. My Name Makes Me Invisible to Computers","url":"https://www.wired.com/2015/11/null/"},{"title":"Patrick McKenzie: Falsehoods Programmers Believe About Names","url":"https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/"}],"tags":["names","validation","forms","database","parsing"],"test_cases":[{"inputs":["Null"],"relation":"returns","expected":"Null","note":"A sanitizer that strips the literal word 'null' will fail this for a real person named Null."}]},{"id":"single-letter-surname-fails-min-length","title":"A 'name must be at least 2 characters' rule rejects a real, single-letter surname","category":"names-addresses","assumption":"No legitimate name is only one character long.","input":{"display":"\"O\" (a one-letter surname)","description":"A form validator's minimum-length rule applied to a surname field."},"what_goes_wrong":"A person whose surname genuinely is a single letter is rejected by a length check that has nothing to do with whether the name is real — it's purely a guess about how short a name 'should' be.","why":"Single-character surnames exist in multiple naming traditions (for example, several Korean surnames romanize to a single letter), and a minimum-length rule has no way to distinguish a genuinely short name from a placeholder like \"x\" someone typed to get past the form. The rule enforces an assumption about length that is simply false for this input, not a check that catches bad data.","affects":["any 'minimum name length' validator"],"repros":[{"language":"python","status":"verified","code":"def validate_name(name):\n if len(name) < 2:\n raise ValueError(\"name must be at least 2 characters\")\n return True\n\nfor name in [\"Yu\", \"O\"]:\n try:\n print(name, validate_name(name))\n except ValueError as e:\n print(name, \"rejected:\", e)\n","expected_output":"Yu True\nO rejected: name must be at least 2 characters","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function validateName(name) {\n if (name.length < 2) throw new Error(\"name must be at least 2 characters\");\n return true;\n}\nfor (const name of [\"Yu\", \"O\"]) {\n try {\n console.log(name, validateName(name));\n } catch (e) {\n console.log(name, \"rejected:\", e.message);\n }\n}\n","expected_output":"Yu true\nO rejected: name must be at least 2 characters","verified_with":"Node.js v22.22.2"}],"fix":"Don't impose a minimum name length unless you have a specific, documented reason to; if you need to catch empty or placeholder input, check for that directly (empty string, all the same character) rather than guessing a length threshold.","related":["mononym-fails-first-last-required"],"sources":[{"title":"Patrick McKenzie: Falsehoods Programmers Believe About Names","url":"https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/"}],"tags":["names","validation","forms","length validation","i18n"],"test_cases":[{"inputs":["O"],"relation":"no_crash","note":"A real single-letter surname should not be rejected purely on length."}]},{"id":"sort-by-surname-particle-convention-mismatch","title":"Sorting people by their stored surname puts \"van der Berg\" after \"de Vries\"; a Dutch phone book would put it before","category":"names-addresses","assumption":"Sorting a list of surnames alphabetically, exactly as the strings are stored, produces the one correct order.","input":{"display":"[\"Bakker\", \"van der Berg\", \"de Vries\"]","description":"A directory, address book, or customer list sorted by a 'last name' field that stores the full surname including any leading particle."},"what_goes_wrong":"Sorting the raw strings files \"van der Berg\" under V, after \"de Vries\"; the Dutch convention drops a leading particle (van, der, de) when computing the sort key, filing \"van der Berg\" under B, ahead of \"de Vries\" under V. The two conventions put these two names in a different relative order.","why":"There is no single universal rule for sorting a surname with a leading particle. The Dutch convention, followed by Dutch directories and by library cataloging rules for Dutch names, drops the particle to compute the sort key; Belgian (Flemish) practice and many other systems keep it. A system that just lowercases and sorts the stored string whole keeps the particle in place. The conventions simply disagree, and most software picks the whole-string sort without ever deciding to.","affects":["any 'sort by last name' feature using the raw stored string","library/bibliographic cataloging convention for particles (documented, not universally applied by software)"],"repros":[{"language":"python","status":"verified","code":"people = [(\"Pieter\", \"Bakker\"), (\"Joost\", \"van der Berg\"), (\"Anna\", \"de Vries\")]\nnaive_order = sorted(people, key=lambda p: p[1].lower())\nprint([p[1] for p in naive_order])\n\nPARTICLES = {\"van\", \"der\", \"de\", \"von\", \"del\", \"la\"}\ndef catalog_key(surname):\n words = surname.split(\" \")\n while words and words[0].lower() in PARTICLES:\n words.pop(0)\n return \" \".join(words).lower()\n\ncatalog_order = sorted(people, key=lambda p: catalog_key(p[1]))\nprint([p[1] for p in catalog_order])\nprint(naive_order == catalog_order)\n","expected_output":"['Bakker', 'de Vries', 'van der Berg']\n['Bakker', 'van der Berg', 'de Vries']\nFalse","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const people = [[\"Pieter\", \"Bakker\"], [\"Joost\", \"van der Berg\"], [\"Anna\", \"de Vries\"]];\nconst naiveOrder = [...people].sort((a, b) => a[1].toLowerCase().localeCompare(b[1].toLowerCase()));\nconsole.log(naiveOrder.map((p) => p[1]));\n\nconst PARTICLES = new Set([\"van\", \"der\", \"de\", \"von\", \"del\", \"la\"]);\nfunction catalogKey(surname) {\n const words = surname.split(\" \");\n while (words.length && PARTICLES.has(words[0].toLowerCase())) words.shift();\n return words.join(\" \").toLowerCase();\n}\nconst catalogOrder = [...people].sort((a, b) => catalogKey(a[1]).localeCompare(catalogKey(b[1])));\nconsole.log(catalogOrder.map((p) => p[1]));\nconsole.log(JSON.stringify(naiveOrder) === JSON.stringify(catalogOrder));\n","expected_output":"[ 'Bakker', 'de Vries', 'van der Berg' ]\n[ 'Bakker', 'van der Berg', 'de Vries' ]\nfalse","verified_with":"Node.js v22.22.2"}],"fix":"Pick a sort-key convention explicitly (whole string vs. particle-dropped) and apply it consistently, rather than sorting by whatever happens to be stored in the surname field, and don't assume there's one universally 'correct' convention; it varies by language and institution.","related":["split-surname-on-last-space-loses-particle"],"sources":[{"title":"Patrick McKenzie: Falsehoods Programmers Believe About Names","url":"https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/"}],"tags":["names","sorting","display","i18n","database"],"test_cases":[{"inputs":["van der Berg","de Vries"],"relation":"no_crash","note":"Decide and document which sort-key convention your directory uses for particled surnames; the two common ones disagree."}]},{"id":"spanish-double-surname-treated-as-single","title":"Taking the last word of \"Lucía Ferrer Montalbán\" as 'the' surname drops a whole surname","category":"names-addresses","assumption":"A person has exactly one surname, which is whichever word comes last in their full name.","input":{"display":"\"Lucía Ferrer Montalbán\" (an invented name)","description":"A full-name field for someone from a Spanish-naming-convention culture, where a person carries both a paternal and a maternal surname, split with a 'last word = surname' heuristic."},"what_goes_wrong":"The system records \"Montalbán\" as the surname and silently drops \"Ferrer\", the first (traditionally paternal) surname, which breaks any later attempt to match this person against relatives who share it, or against official documents that list both.","why":"In Spain and most of Spanish-speaking Latin America, a person normally carries TWO surnames, both legally part of the name and neither a middle name; traditionally the first is the father's first surname and the second is the mother's first surname (Spain has let parents choose the order since 1999). A 'last word is the surname' rule, which works for a simple two-part name, discards the first of the two surnames because it has no way to know that this particular name has three meaningful parts, not two.","affects":["any 'last word is the surname' name parser applied to a spanish-convention full name"],"repros":[{"language":"python","status":"verified","code":"full = \"Luc\\u00eda Ferrer Montalb\\u00e1n\"\nparts = full.split(\" \")\ngiven, surname = parts[0], parts[-1]\nprint(\"given:\", given, \"| recorded surname:\", surname, \"| dropped:\", parts[1:-1])\n","expected_output":"given: Lucía | recorded surname: Montalbán | dropped: ['Ferrer']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const full = \"Luc\\u00eda Ferrer Montalb\\u00e1n\";\nconst parts = full.split(\" \");\nconst given = parts[0];\nconst surname = parts[parts.length - 1];\nconsole.log(\"given:\", given, \"| recorded surname:\", surname, \"| dropped:\", parts.slice(1, -1));\n","expected_output":"given: Lucía | recorded surname: Montalbán | dropped: [ 'Ferrer' ]","verified_with":"Node.js v22.22.2"}],"fix":"Don't assume a fixed name structure derived from word position; ask for given name(s) and surname(s) as separate, explicitly-labeled fields, and allow more than one surname.","related":["split-surname-on-last-space-loses-particle"],"sources":[{"title":"Patrick McKenzie: Falsehoods Programmers Believe About Names","url":"https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/"}],"tags":["names","forms","validation","database","i18n"],"test_cases":[{"inputs":["Lucía Ferrer Montalbán"],"relation":"no_crash","note":"Check whether your surname extraction keeps both 'Ferrer' and 'Montalbán', or silently drops one."}]},{"id":"split-surname-on-last-space-loses-particle","title":"Splitting a name on the last space doesn't find the surname — it finds the last WORD","category":"names-addresses","assumption":"The text after the last space in a full name is the surname.","input":{"display":"\"Joost van der Berg\" (an invented name)","description":"A 'full name' text field being split into given/surname for a database or a form, using the common 'last word = surname' heuristic."},"what_goes_wrong":"The surname field ends up holding just \"Berg\", and \"van der\" — which is part of the actual family name \"van der Berg\" — gets silently folded into the given-name field instead.","why":"Splitting on the last whitespace boundary only knows about whitespace, not about which words belong to the family name. Dutch, German, and other surnames routinely include lowercase particles (van, der, von, de) that are grammatically and legally part of the surname, not separate given names, and a position-based split has no way to know where the surname actually starts.","affects":["any 'split full name on whitespace' form handler","python str.rsplit","javascript lastIndexOf-based split"],"repros":[{"language":"python","status":"verified","code":"def naive_split(full_name):\n given, surname = full_name.rsplit(\" \", 1)\n return given, surname\n\nprint(naive_split(\"Joost van der Berg\"))\n","expected_output":"('Joost van der', 'Berg')","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function naiveSplit(fullName) {\n const i = fullName.lastIndexOf(\" \");\n return [fullName.slice(0, i), fullName.slice(i + 1)];\n}\nconsole.log(naiveSplit(\"Joost van der Berg\"));\n","expected_output":"[ 'Joost van der', 'Berg' ]","verified_with":"Node.js v22.22.2"}],"fix":"Collect given-name and family-name as separate fields entered directly by the person instead of inferring the split from word position; if you must infer it, maintain an explicit list of known lowercase surname particles to fold into the family-name side.","related":["mononym-fails-first-last-required","spanish-double-surname-treated-as-single"],"sources":[{"title":"Patrick McKenzie: Falsehoods Programmers Believe About Names","url":"https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/"}],"tags":["names","forms","validation","database","i18n","display"],"test_cases":[{"inputs":["Joost van der Berg"],"relation":"no_crash","note":"Check whether your surname field comes out as 'Berg' or 'van der Berg' — most systems get this wrong."}]},{"id":"title-case-mangles-mixed-case-and-apostrophes","title":"str.title() turns \"mcdonald\" into \"Mcdonald\" and \"they're\" into \"They'Re\"","category":"names-addresses","assumption":"A built-in title-case function always produces the conventionally correct capitalization of a name.","input":{"display":"\"mcdonald\", \"they're\"","description":"A lowercase or all-caps name from a legacy database or a careless import, being 'fixed up' for display with a generic title-casing function."},"what_goes_wrong":"\"mcdonald\" displays as \"Mcdonald\" instead of \"McDonald\", and a word containing an apostrophe like \"they're\" gets a capital letter injected right after the apostrophe, becoming \"They'Re\".","why":"Python's str.title() capitalizes the first letter of every maximal run of alphabetic characters and lowercases the rest; it has no dictionary of surname conventions, so it has no way to know the 'D' in McDonald should also be capitalized, and it treats an apostrophe as a word boundary just like a space — which happens to look right for a name like \"o'neil\" but is wrong for a contraction like \"they're\", capitalizing the letter right after the apostrophe either way. The common hand-rolled JavaScript equivalent (capitalize the first character of each whitespace-separated token) fails differently: it also gives \"Mcdonald\", but treats \"o'neil\" as one token and returns \"O'neil\", missing the capital that name needs.","affects":["python str.title()"],"repros":[{"language":"python","status":"verified","code":"print(\"mcdonald\".title())\nprint(\"o'neil\".title())\nprint(\"they're\".title())\n","expected_output":"Mcdonald\nO'Neil\nThey'Re","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function titleCase(s) {\n return s.replace(/\\w\\S*/g, (t) => t[0].toUpperCase() + t.slice(1).toLowerCase());\n}\nconsole.log(titleCase(\"mcdonald\"));\nconsole.log(titleCase(\"o'neil\"));\n","expected_output":"Mcdonald\nO'neil","verified_with":"Node.js v22.22.2"}],"fix":"Don't auto-capitalize a name with a generic title-casing function; either store and display the name exactly as the person entered it, or maintain an explicit exception list for known patterns (Mc/Mac, O', particles) and accept that no automatic rule covers every name.","related":["ascii-regex-rejects-accented-and-hyphenated-names"],"sources":[{"title":"Python docs: str.title()","url":"https://docs.python.org/3/library/stdtypes.html#str.title"},{"title":"Patrick McKenzie: Falsehoods Programmers Believe About Names","url":"https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/"}],"tags":["names","display","validation","forms","i18n"],"test_cases":[{"inputs":["mcdonald"],"relation":"returns","expected":"McDonald","note":"A generic title-case function will almost certainly fail this — decide whether you want to auto-capitalize names at all before relying on one."}]},{"id":"uk-canada-postal-code-assumed-numeric","title":"A 'postal code must be 5 digits' check rejects every UK and Canadian address","category":"names-addresses","assumption":"A postal/zip code is always a short string of digits.","input":{"display":"\"SW1A 1AA\" (UK), \"K1A 0B1\" (Canada)","description":"A postal code field validated against a fixed numeric pattern, written with only US ZIP codes in mind."},"what_goes_wrong":"Perfectly valid UK and Canadian postal codes are rejected outright because they mix letters and digits and don't match a 5-digit pattern at all.","why":"US ZIP codes happen to be exactly 5 digits, but that's a US-specific format, not a property of 'postal codes' in general. UK postcodes (like SW1A 1AA) and Canadian postal codes (like K1A 0B1) are alphanumeric by design, with letter-digit structures that a 5-digit-only regex has no way to match.","affects":["any postal-code validator hard-coded to \\d{5}"],"repros":[{"language":"python","status":"verified","code":"import re\npattern = re.compile(r\"^\\d{5}$\")\nfor code in [\"02134\", \"SW1A 1AA\", \"K1A 0B1\"]:\n print(code, bool(pattern.match(code)))\n","expected_output":"02134 True\nSW1A 1AA False\nK1A 0B1 False","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const pattern = /^\\d{5}$/;\nfor (const code of [\"02134\", \"SW1A 1AA\", \"K1A 0B1\"]) {\n console.log(code, pattern.test(code));\n}\n","expected_output":"02134 true\nSW1A 1AA false\nK1A 0B1 false","verified_with":"Node.js v22.22.2"}],"fix":"Validate postal codes per-country using that country's actual format (or accept any reasonably short alphanumeric string if the country isn't known), never a single 5-digit pattern applied worldwide.","related":["us-zip-code-leading-zero-lost-as-integer"],"sources":[{"title":"Michael Tandy: Falsehoods Programmers Believe About Addresses","url":"https://www.mjt.me.uk/posts/falsehoods-programmers-believe-about-addresses/"}],"tags":["addresses","validation","forms","i18n","database"],"test_cases":[{"inputs":["SW1A 1AA"],"relation":"no_crash","note":"A valid UK postcode should be accepted by your postal-code field."}]},{"id":"us-zip-code-leading-zero-lost-as-integer","title":"A Boston ZIP code, \"02134\", becomes 2134 the moment anything parses it as a number","category":"names-addresses","assumption":"A ZIP code can be safely stored or parsed as an integer; it's 'just a number' after all.","input":{"display":"\"02134\" parsed with int() / parseInt()","description":"A US ZIP code stored in a numeric database column, or passed through a spreadsheet import / schema that infers 'number' for a 5-digit-looking string."},"what_goes_wrong":"The leading zero silently disappears, turning a real, valid ZIP code into a 4-digit number that fails every later format check and no longer matches the original address.","why":"A ZIP code is an identifier made of digit CHARACTERS, not a numeric quantity; there's no meaningful arithmetic anyone would do with it. int()/parseInt()/Number() all discard leading zeros because they parse decimal numeric VALUE, and the value of 02134 is mathematically identical to 2134 — the leading zero carries no numeric information, but it is semantically part of the identifier.","affects":["python int()","javascript parseInt()/Number()","any database column typed as an integer for a zip/postal code"],"repros":[{"language":"python","status":"verified","code":"zip_code = \"02134\"\nas_int = int(zip_code)\nprint(as_int, str(as_int))\nprint(str(as_int) == zip_code)\n","expected_output":"2134 2134\nFalse","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const zipCode = \"02134\";\nconst asInt = parseInt(zipCode, 10);\nconsole.log(asInt, String(asInt));\nconsole.log(String(asInt) === zipCode);\n","expected_output":"2134 2134\nfalse","verified_with":"Node.js v22.22.2"}],"fix":"Store postal codes as strings, never as a numeric type, and never round-trip them through int()/parseInt()/Number() — there is nothing numeric about an identifier that happens to look like digits.","related":["uk-canada-postal-code-assumed-numeric"],"sources":[{"title":"Michael Tandy: Falsehoods Programmers Believe About Addresses","url":"https://www.mjt.me.uk/posts/falsehoods-programmers-believe-about-addresses/"}],"tags":["addresses","parsing","database","validation","forms"],"test_cases":[{"inputs":["02134"],"relation":"returns","expected":"02134","note":"Round-tripping a ZIP code through your storage layer should preserve the leading zero."}]},{"id":"varchar-truncation-splits-name-mid-grapheme","title":"Truncating a name to a fixed length can cut a letter in half, dropping its accent silently","category":"names-addresses","assumption":"Truncating a string to N characters always leaves N valid, complete characters.","input":{"display":"\"Nguy\\u1ec5n V\\u0103n H\\u01b0ng\" (an invented, common-pattern Vietnamese name) normalized to NFD, truncated to 11 code points","description":"A name stored in decomposed Unicode form (common after certain input methods or normalization steps) being cut down to fit a fixed-width column or display field."},"what_goes_wrong":"The cut lands between a base letter and its combining accent mark, silently dropping the accent and turning \"Văn\" into \"Va\", with no error, warning, or visible corruption in the truncation step itself.","why":"In NFD (decomposed) form, an accented letter like ă is represented as TWO code points: the base letter 'a' followed by a separate combining breve mark. A length-based slice has no concept of 'this code point belongs with the previous one'; it just counts positions, so a cut that lands exactly between the base letter and its combining mark keeps the bare base letter and discards the accent, producing a different, valid-looking string that is quietly wrong rather than visibly broken.","affects":["any code-point-count truncation (string slicing, SQL LEFT()/SUBSTRING(), an explicit cast to varchar(n)) applied to NFD text","python string slicing","javascript string slicing"],"repros":[{"language":"python","status":"verified","code":"import unicodedata\nname = unicodedata.normalize(\"NFD\", \"Nguy\\u1ec5n V\\u0103n H\\u01b0ng\")\nprint(\"full (NFC):\", unicodedata.normalize(\"NFC\", name))\ntruncated = name[:11]\nprint(\"truncated (NFC):\", unicodedata.normalize(\"NFC\", truncated))\n","expected_output":"full (NFC): Nguyễn Văn Hưng\ntruncated (NFC): Nguyễn Va","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const name = \"Nguy\\u1ec5n V\\u0103n H\\u01b0ng\".normalize(\"NFD\");\nconsole.log(\"full (NFC):\", name.normalize(\"NFC\"));\nconst truncated = name.slice(0, 11);\nconsole.log(\"truncated (NFC):\", truncated.normalize(\"NFC\"));\n","expected_output":"full (NFC): Nguyễn Văn Hưng\ntruncated (NFC): Nguyễn Va","verified_with":"Node.js v22.22.2"}],"fix":"Normalize to NFC before storing or measuring length (precomposed accented letters are single code points, far less likely to be split), and when you must truncate, cut at a grapheme-cluster boundary, not a raw code-point or UTF-16-unit count.","related":["nfc-nfd-same-name-two-accounts","nfc-nfd-filename-lookup-mismatch"],"sources":[{"title":"Unicode Standard Annex #29: Unicode Text Segmentation (grapheme clusters)","url":"https://www.unicode.org/reports/tr29/"},{"title":"Unicode Standard Annex #15: Unicode Normalization Forms","url":"https://www.unicode.org/reports/tr15/"}],"tags":["names","truncation","length validation","database","i18n","unicode"],"test_cases":[{"inputs":["Nguyễn Văn Hưng"],"relation":"no_crash","note":"Feed your truncation helper this name in NFD form with a limit of 11: a grapheme-aware truncation keeps \"Nguyễn V\" or \"Nguyễn Vă\", never a bare \"a\" stripped of its breve."}]},{"id":"csv-crlf-trailing-blank-line","title":"A trailing blank line in a CSV file parses as a real (empty) row, and indexing into it throws","category":"parsing","assumption":"A CSV file's trailing newline (or a blank line left at the end by whatever produced it) just gets dropped, so the number of parsed rows always equals the number of visible data lines.","input":{"display":"a,b\\n1,2\\n\\n (note the extra blank line before end of file)","description":"A CSV generated by exporting from a spreadsheet or database tool that leaves one truly empty line at the end of the file, as opposed to a single ordinary trailing newline after the last data row (which is harmless)."},"what_goes_wrong":"A single trailing line ending after the last row (`a,b\\r\\n1,2\\r\\n3,4\\r\\n`) parses cleanly as three rows - but an extra blank line (`a,b\\n1,2\\n\\n`) parses as three rows in Python where the last one is a genuinely empty list `[]`, so `rows[-1][0]` raises `IndexError: list index out of range`. The JS naive `split(\"\\n\")` equivalent doesn't crash on this particular shape, but produces two phantom `[\"\"]` rows.","why":"A CSV reader has to distinguish \"the file ends after a newline\" (no extra row) from \"there is a genuinely empty line in the data\" (a zero-field row). Python's `csv` module does the former correctly and represents the latter faithfully as `[]`, which is correct behavior but a shape callers rarely expect to handle, so indexing into the last row without checking its length crashes. Plain `text.split(\"\\n\")` in JavaScript produces a trailing empty string element whenever the text ends in a newline at all, so naive code gets an extra `[\"\"]`-shaped phantom row even for the harmless single-trailing-newline case that Python's real parser handles cleanly.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import csv, io\nclean = 'a,b\\r\\n1,2\\r\\n3,4\\r\\n'\nprint(list(csv.reader(io.StringIO(clean))))\nwith_blank = 'a,b\\n1,2\\n\\n'\nrows = list(csv.reader(io.StringIO(with_blank)))\nprint(rows)\ntry:\n print(rows[-1][0])\nexcept IndexError as e:\n print(\"IndexError:\", e)\n","expected_output":"[['a', 'b'], ['1', '2'], ['3', '4']]\n[['a', 'b'], ['1', '2'], []]\nIndexError: list index out of range","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const withBlank = \"a,b\\n1,2\\n\\n\";\nconst naiveRows = withBlank.split(\"\\n\").map(line => line.split(\",\"));\nconsole.log(JSON.stringify(naiveRows));\nconsole.log(naiveRows[naiveRows.length - 1][0]);\n","expected_output":"[[\"a\",\"b\"],[\"1\",\"2\"],[\"\"],[\"\"]]","verified_with":"Node.js v22.22.2"}],"fix":"Filter out zero-length rows (Python: `if row` after reading) or trailing empty lines before splitting (JS), and never index into the last parsed row without checking it has the expected number of fields first.","related":["csv-utf8-bom-first-header"],"sources":[{"title":"Python docs: csv - CSV File Reading and Writing","url":"https://docs.python.org/3/library/csv.html"},{"title":"RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files","url":"https://www.rfc-editor.org/rfc/rfc4180"}],"tags":["csv","upload","parsing","validation"],"test_cases":[{"inputs":["a,b\n1,2\n\n"],"relation":"returns","expected":[["a","b"],["1","2"],[]],"note":"A CSV-parsing function given a file with a genuinely blank trailing line should represent it as an empty row (as Python's csv.reader does) rather than crashing - callers must then check row length before indexing."},{"inputs":["a,b\r\n1,2\r\n"],"relation":"returns","expected":[["a","b"],["1","2"]],"note":"A single CRLF after the last row is just the line terminator: exactly two rows, no phantom empty row and no stray \\r in the last field."}]},{"id":"csv-doubled-quote-escaping","title":"A literal quote mark inside a CSV field is written as two quote marks, not a backslash-escaped one","category":"parsing","assumption":"A quote character inside a CSV field is escaped the way it is in most languages, with a backslash.","input":{"display":"name,quote\\n\"Say \"\"hello\"\"\",5\\n","description":"A CSV export of a field whose text contains a literal double-quote character, e.g. a product name like `Say \"hello\"` or a quoted phrase inside a review."},"what_goes_wrong":"A field containing a literal `\"` is represented in CSV as `\"\"` (two quote characters) inside the surrounding quoted field, not `\\\"` - code that naively strips a single leading/trailing quote and un-escapes backslash-quote sequences (a common pattern borrowed from JSON/string literals) will mangle the field instead of recovering `Say \"hello\"`.","why":"RFC 4180's escape mechanism for a literal double-quote inside a quoted field is to double it - `\"\"` means one literal `\"` - with no backslash involved anywhere in the format. A real CSV parser (Python's `csv` module) recognizes a `\"\"` sequence while inside a quoted field as an escaped quote and collapses it to one `\"` in the field's value; it is a dialect-specific rule that has nothing to do with how any programming language escapes string literals.","affects":["javascript","any backslash-escape-based CSV handling"],"repros":[{"language":"python","status":"verified","code":"import csv, io\ntext = 'name,quote\\n\"Say \"\"hello\"\"\",5\\n'\nprint(list(csv.reader(io.StringIO(text))))\n","expected_output":"[['name', 'quote'], ['Say \"hello\"', '5']]","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const text = 'name,quote\\n\"Say \"\"hello\"\"\",5\\n';\n// naive: strip outer quotes, un-escape backslash-quote (wrong model for CSV)\nconst line = text.split(\"\\n\")[1];\nconst naiveFields = line.split(\",\");\nconsole.log(JSON.stringify(naiveFields));\n","expected_output":"[\"\\\"Say \\\"\\\"hello\\\"\\\"\\\"\",\"5\"]","verified_with":"Node.js v22.22.2"}],"fix":"Use a real CSV parser that implements RFC 4180's doubled-quote escaping, rather than any backslash-based unescaping borrowed from another format. If writing CSV by hand, double every `\"` inside a field and wrap the whole field in quotes.","related":["csv-quoted-field-with-comma","csv-quoted-field-with-newline"],"sources":[{"title":"RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files","url":"https://www.rfc-editor.org/rfc/rfc4180"},{"title":"Python docs: csv - CSV File Reading and Writing","url":"https://docs.python.org/3/library/csv.html"}],"tags":["csv","upload","parsing","validation"],"test_cases":[{"inputs":["name,quote\n\"Say \"\"hello\"\"\",5\n"],"relation":"returns","expected":[["name","quote"],["Say \"hello\"","5"]],"note":"A CSV-parsing function given this text must collapse the doubled quotes into one literal quote character inside the field value."}]},{"id":"csv-formula-injection-excel","title":"A CSV cell that parses as a harmless string can still execute as a formula the moment someone opens it in Excel","category":"parsing","assumption":"If a CSV parser reads a cell's value back as the exact literal string that was written, the value is safe wherever the file ends up.","input":{"display":"name,amount\\n\"=cmd|' /C calc'!A0\",100\\n","description":"User-controlled text (a name, a comment, an address) exported into a CSV/XLSX that will be opened by a human in Excel, Google Sheets, or LibreOffice Calc - any cell value beginning with `=`, `+`, `-`, or `@` is a candidate formula to a spreadsheet application."},"what_goes_wrong":"A CSV writer/reader round-trips the attacker's string perfectly and faithfully - the field comes back exactly as `=cmd|' /C calc'!A0` - so nothing in the CSV layer looks wrong. But a spreadsheet application opening that same file treats any cell starting with `=`, `+`, `-`, or `@` as a formula to evaluate, which in older Excel versions could shell out via DDE (`cmd|...!A0`) or exfiltrate data to a remote URL through functions like `IMPORTXML` in Sheets - entirely outside what CSV parsing itself can see or prevent.","why":"CSV has no concept of formulas; a cell is just delimited text. The danger is downstream: spreadsheet applications overload CSV as an import format for their own formula language, and that interpretation triggers on specific leading characters regardless of what the data \"means\" to your application. OWASP catalogs this as CSV Injection - the fix lives entirely on the writer side (how you emit values a spreadsheet will later open), because the CSV format and a conformant CSV parser are both working exactly as specified.","affects":["python","javascript","any CSV exported for a human to open in a spreadsheet application"],"repros":[{"language":"python","status":"verified","code":"import csv, io\n\ndef sanitize(value):\n if isinstance(value, str) and value and value[0] in (\"=\", \"+\", \"-\", \"@\"):\n return \"'\" + value\n return value\n\npayload = '=cmd|\\' /C calc\\'!A0'\nraw = io.StringIO()\ncsv.writer(raw).writerow([\"name\", \"amount\"])\ncsv.writer(raw).writerow([payload, \"100\"])\nprint(repr(raw.getvalue()))\nparsed = list(csv.reader(io.StringIO(raw.getvalue())))\nprint(parsed[1][0] == payload)\n\nsafe = io.StringIO()\ncsv.writer(safe).writerow([sanitize(payload), \"100\"])\nprint(repr(safe.getvalue()))\n","expected_output":"\"name,amount\\r\\n=cmd|' /C calc'!A0,100\\r\\n\"\nTrue\n\"'=cmd|' /C calc'!A0,100\\r\\n\"","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"function sanitize(value) {\n if (typeof value === \"string\" && value.length && \"=+-@\".includes(value[0])) return \"'\" + value;\n return value;\n}\nconst payload = \"=cmd|' /C calc'!A0\";\nconsole.log(sanitize(payload));\nconsole.log(sanitize(\"100\"));\nconsole.log(sanitize(\"Smith\"));\n","expected_output":"'=cmd|' /C calc'!A0\n100\nSmith","verified_with":"Node.js v22.22.2"}],"fix":"Prefix any user-controlled cell value that starts with `=`, `+`, `-`, or `@` (and, in some guidance, tab or carriage return) with a single quote or another neutralizing character before writing it to a file a human will open in a spreadsheet. Parsing the CSV correctly is necessary but not sufficient - the sanitization has to happen on write, aimed at the spreadsheet application's behavior, not the CSV format's.","related":["csv-doubled-quote-escaping"],"sources":[{"title":"OWASP: CSV Injection","url":"https://owasp.org/www-community/attacks/CSV_Injection"},{"title":"Python docs: csv - CSV File Reading and Writing","url":"https://docs.python.org/3/library/csv.html"}],"tags":["csv","upload","parsing","security","validation"],"test_cases":[{"inputs":["name,amount\n\"=cmd|' /C calc'!A0\",100\n"],"relation":"returns","expected":[["name","amount"],["=cmd|' /C calc'!A0","100"]],"note":"A CSV-upload parser must return the cell exactly as written - the defence belongs on the export side (prefix values starting with =, +, -, @ with a single quote before writing a file a human will open in a spreadsheet), not in silently altering parsed data."}]},{"id":"csv-quoted-field-with-comma","title":"line.split(\",\") turns one quoted name into two broken columns","category":"parsing","assumption":"Splitting a CSV line on \",\" gives you the row's fields.","input":{"display":"name,amount\\n\"Smith, John\",100\\n","description":"An uploaded CSV where a free-text field - a person's name written \"Last, First\", a street address, a product description - itself contains a comma, which RFC 4180 quoting exists specifically to protect."},"what_goes_wrong":"`line.split(\",\")` on the data row produces three fields - `'\"Smith'`, `' John\"'`, `'100'` - instead of the two the row actually has, shifting every later naive-split column over by one.","why":"A comma inside a quoted field is data, not a delimiter - RFC 4180 (and every real CSV dialect) says a field wrapped in double quotes can contain commas, newlines, or anything else, and the quotes are what tells a parser to stop treating that comma as a separator. `str.split(\",\")` has no concept of quoting; it is a dumb character split, so it breaks the field at the comma regardless of the surrounding quotes. A real CSV parser (Python's `csv` module) tracks quote state and only splits on commas outside quotes.","affects":["javascript","any naive split(\",\") CSV parser"],"repros":[{"language":"python","status":"verified","code":"import csv, io\ntext = 'name,amount\\n\"Smith, John\",100\\n'\nnaive = [line.split(\",\") for line in text.splitlines()]\nprint(naive)\nproper = list(csv.reader(io.StringIO(text)))\nprint(proper)\n","expected_output":"[['name', 'amount'], ['\"Smith', ' John\"', '100']]\n[['name', 'amount'], ['Smith, John', '100']]","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const text = 'name,amount\\n\"Smith, John\",100\\n';\nconst naive = text.trim().split(\"\\n\").map(line => line.split(\",\"));\nconsole.log(JSON.stringify(naive));\n","expected_output":"[[\"name\",\"amount\"],[\"\\\"Smith\",\" John\\\"\",\"100\"]]","verified_with":"Node.js v22.22.2"}],"fix":"Never parse CSV with split(\",\"). Use a real CSV parser - Python's stdlib `csv` module, or a maintained library in JS (there is no CSV parser in the JS standard library) - that understands RFC 4180 quoting.","related":["csv-quoted-field-with-newline","csv-doubled-quote-escaping"],"sources":[{"title":"RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files","url":"https://www.rfc-editor.org/rfc/rfc4180"},{"title":"Python docs: csv - CSV File Reading and Writing","url":"https://docs.python.org/3/library/csv.html"}],"tags":["csv","upload","parsing","validation"],"test_cases":[{"inputs":["name,amount\n\"Smith, John\",100\n"],"relation":"returns","expected":[["name","amount"],["Smith, John","100"]],"note":"A CSV-parsing function given this text must return two rows of two fields each, with the comma inside the quoted name preserved as part of that field's value."}]},{"id":"csv-quoted-field-with-newline","title":"Splitting a CSV file into lines first breaks a row that has a quoted newline in it","category":"parsing","assumption":"Each line of a CSV file is one row, so splitting the file on newlines before parsing fields is safe.","input":{"display":"name,note\\n\"Smith\",\"Line1\\nLine2\"\\n","description":"A CSV export of a notes or address field that contains an embedded newline (a multi-line note, a two-line mailing address) wrapped in quotes, as RFC 4180 requires."},"what_goes_wrong":"Splitting the raw text into lines first (`text.splitlines()` in Python, `text.split(\"\\n\")` in JS) turns one logical two-field row into three separate \"lines\" - the quoted field's embedded newline is treated as a row boundary before the quoting is ever examined, so no amount of comma-aware parsing afterward can put the row back together.","why":"RFC 4180 allows a quoted field to contain literal newline characters as part of its value - a row boundary is only the newline that appears outside a quoted field. A real CSV parser (Python's `csv` module) reads character by character and tracks whether it is currently inside a quote, so it does not treat a newline as a row separator while inside quotes. Pre-splitting the text into lines with `splitlines()`/`split(\"\\n\")` throws that context away before the parser ever runs, so the damage is already done.","affects":["javascript","any CSV handling that splits into lines before parsing fields"],"repros":[{"language":"python","status":"verified","code":"import csv, io\ntext = 'name,note\\n\"Smith\",\"Line1\\nLine2\"\\n'\nprint(text.splitlines())\nnaive = [line.split(\",\") for line in text.splitlines()]\nprint(naive)\nproper = list(csv.reader(io.StringIO(text)))\nprint(proper)\n","expected_output":"['name,note', '\"Smith\",\"Line1', 'Line2\"']\n[['name', 'note'], ['\"Smith\"', '\"Line1'], ['Line2\"']]\n[['name', 'note'], ['Smith', 'Line1\\nLine2']]","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const text = 'name,note\\n\"Smith\",\"Line1\\nLine2\"\\n';\nconst naiveLines = text.split(\"\\n\");\nconsole.log(JSON.stringify(naiveLines));\n","expected_output":"[\"name,note\",\"\\\"Smith\\\",\\\"Line1\",\"Line2\\\"\",\"\"]","verified_with":"Node.js v22.22.2"}],"fix":"Never split a CSV file into lines before parsing fields. Hand the whole text (or a stream) to a real quote-aware CSV parser - Python's `csv.reader`, or a maintained CSV library in JS - and let it decide where rows actually end.","related":["csv-quoted-field-with-comma","csv-doubled-quote-escaping"],"sources":[{"title":"RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files","url":"https://www.rfc-editor.org/rfc/rfc4180"},{"title":"Python docs: csv - CSV File Reading and Writing","url":"https://docs.python.org/3/library/csv.html"}],"tags":["csv","upload","parsing","validation"],"test_cases":[{"inputs":["name,note\n\"Smith\",\"Line1\nLine2\"\n"],"relation":"returns","expected":[["name","note"],["Smith","Line1\nLine2"]],"note":"A CSV-parsing function given this text must return two rows, with the embedded newline preserved inside the second field's value rather than splitting into three rows."}]},{"id":"csv-semicolon-delimited-european-excel","title":"A \"CSV\" from a European Excel is delimited with semicolons, and a comma-delimiter parser reads the whole line as one field","category":"parsing","assumption":"A file with a .csv extension is comma-delimited.","input":{"display":"name;age\\nAda;30\\n","description":"A CSV exported by Excel configured for a locale (most of continental Europe) where the decimal comma is the default decimal separator - Excel then uses a semicolon as the list/field separator instead of a comma, to avoid colliding with decimal commas in numeric fields, while still naming the file .csv."},"what_goes_wrong":"Parsing this file with a comma-delimited reader produces one single-column row per line - `['name;age']`, `['Ada;30']` - instead of the two columns the file actually has; every downstream column lookup by index or name silently gets the wrong (and only) field.","why":"\".csv\" names a family of delimited-text formats, not a guarantee of which character is the delimiter. When Excel on Windows saves as CSV it uses the operating system's regional \"list separator\" setting, and in locales whose decimal separator is a comma (most of continental Europe) that setting defaults to a semicolon, so numbers like 3,5 don't collide with the field separator. The file still gets the same \".csv\" extension. A parser that hardcodes `delimiter=\",\"` has no way to detect this from the extension alone.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import csv, io\ntext = 'name;age\\nAda;30\\n'\nprint(list(csv.reader(io.StringIO(text))))\nprint(list(csv.reader(io.StringIO(text), delimiter=\";\")))\n","expected_output":"[['name;age'], ['Ada;30']]\n[['name', 'age'], ['Ada', '30']]","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const text = \"name;age\\nAda;30\\n\";\nconst commaDelimited = text.trim().split(\"\\n\").map(line => line.split(\",\"));\nconst semicolonDelimited = text.trim().split(\"\\n\").map(line => line.split(\";\"));\nconsole.log(JSON.stringify(commaDelimited));\nconsole.log(JSON.stringify(semicolonDelimited));\n","expected_output":"[[\"name;age\"],[\"Ada;30\"]]\n[[\"name\",\"age\"],[\"Ada\",\"30\"]]","verified_with":"Node.js v22.22.2"}],"fix":"Sniff the delimiter instead of hardcoding it - Python's `csv.Sniffer().sniff(sample)` guesses it from a sample of the file, or check the first line for the most frequent of `,`, `;`, and `\\t` before choosing a reader. Let users confirm the detected delimiter on upload rather than failing silently.","related":["csv-utf8-bom-first-header"],"sources":[{"title":"Python docs: csv.Sniffer","url":"https://docs.python.org/3/library/csv.html#csv.Sniffer"},{"title":"RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files","url":"https://www.rfc-editor.org/rfc/rfc4180"}],"tags":["csv","upload","parsing","i18n","validation"],"test_cases":[{"inputs":["name;age\nAda;30\n"],"relation":"returns","expected":[["name","age"],["Ada","30"]],"note":"A CSV-parsing function that auto-detects the delimiter should return two columns for this semicolon-delimited file, not one column containing the whole line."}]},{"id":"csv-utf8-bom-first-header","title":"A CSV header named \"name\" comes back as \"\\ufeffname\" and every lookup by column name fails","category":"parsing","assumption":"Decoding an uploaded CSV file as UTF-8 and reading its first header exactly recovers the column name the file's first line shows on screen.","input":{"display":"bytes: EF BB BF 6e 61 6d 65 2c 61 67 65 0a 41 64 61 2c 33 30 0a (\"\\ufeffname,age\\nAda,30\\n\")","description":"A CSV saved from Excel with the \"CSV UTF-8 (Comma delimited)\" format (and by many other Windows tools), which writes a UTF-8 byte-order mark at the very start of the file - invisible in a spreadsheet and in most text editors, but present as real bytes. (Excel's plain \"CSV (Comma delimited)\" format writes no BOM, and uses the system code page rather than UTF-8.)"},"what_goes_wrong":"Decoding the file's bytes as plain `\"utf-8\"` leaves the BOM as a literal U+FEFF character glued onto the first header, so the first column parses as `\"\\ufeffname\"` instead of `\"name\"` - a lookup like `row[\"name\"]` throws a KeyError even though the column is clearly named \"name\" in every spreadsheet viewer.","why":"A UTF-8 BOM is three specific bytes (EF BB BF) that some encoders prepend to mark a file as UTF-8; it decodes to the single code point U+FEFF. Decoding with Python's `\"utf-8\"` codec or Node's `Buffer.toString(\"utf8\")` passes that code point through unchanged as the first character of the text. Python's `\"utf-8-sig\"` codec, and JavaScript's `TextDecoder(\"utf-8\")` (whose spec-default `ignoreBOM: false` means the BOM *is* processed/stripped, despite the confusing name), both detect and strip a leading BOM - but only if you use them instead of the plain decoder.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"raw = '\\ufeffname,age\\nAda,30\\n'.encode(\"utf-8\")\nimport csv, io\nplain = raw.decode(\"utf-8\")\nsig = raw.decode(\"utf-8-sig\")\nprint(list(csv.reader(io.StringIO(plain)))[0])\nprint(list(csv.reader(io.StringIO(sig)))[0])\n","expected_output":"['\\ufeffname', 'age']\n['name', 'age']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const bytes = Buffer.from(\"\\ufeffname,age\\nAda,30\\n\", \"utf8\");\nconst plain = bytes.toString(\"utf8\");\nconst stripped = new TextDecoder(\"utf-8\").decode(bytes);\nconsole.log(JSON.stringify(plain.split(\",\")[0]));\nconsole.log(JSON.stringify(stripped.split(\",\")[0]));\n","expected_output":"\"name\"\n\"name\"","verified_with":"Node.js v22.22.2"}],"fix":"Decode uploaded CSV/text files with `encoding=\"utf-8-sig\"` in Python (it strips a BOM if present and is a no-op if absent), or use `TextDecoder(\"utf-8\")` (not a raw `Buffer.toString`) in Node, which strips the BOM by default. Alternatively, explicitly strip a leading `\\ufeff` from the decoded text before parsing.","related":["csv-crlf-trailing-blank-line"],"sources":[{"title":"Python docs: codecs - encodings and Unicode (utf-8-sig)","url":"https://docs.python.org/3/library/codecs.html#encodings-and-unicode"},{"title":"WHATWG Encoding Standard: UTF-8 decoder (BOM handling)","url":"https://encoding.spec.whatwg.org/#utf-8-decoder"},{"title":"MDN: TextDecoder() constructor (ignoreBOM option)","url":"https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/TextDecoder"}],"tags":["csv","upload","parsing","validation","i18n"],"test_cases":[{"inputs":["name,age\nAda,30\n"],"relation":"returns","expected":[["name","age"],["Ada","30"]],"note":"A CSV-parsing function given text starting with a BOM character should strip it, so the header reads exactly \"name\" rather than \"\\ufeffname\"."}]},{"id":"email-regex-rejects-valid-apostrophe-address","title":"A common signup-form email regex rejects o'brien@example.com but accepts user+tag@example.com","category":"parsing","assumption":"A regex like ^[\\w.+-]+@[\\w-]+\\.[a-zA-Z]{2,}$ is a reasonable, slightly-strict stand-in for \"is this a valid email address\".","input":{"display":"o'brien@example.com","description":"A signup, checkout, or newsletter form's email field, validated client- or server-side with a hand-written regex, used by a person whose email address's local part (the part before @) contains a punctuation character the regex author didn't anticipate - most commonly an apostrophe in a surname."},"what_goes_wrong":"`/^[\\w.+-]+@[\\w-]+\\.[a-zA-Z]{2,}$/` rejects `o'brien@example.com` (valid, real-world mail address) while accepting `user+tag@example.com` (plus-addressing, also valid) - the regex's author remembered to allow `+` for Gmail-style tagging but not `'`, so a real customer's real address bounces at the form while a less common but equally valid syntax sails through.","why":"RFC 5322 section 3.2.3 defines the characters allowed in an unquoted local-part (`atext`) as letters, digits and `! # $ % & ' * + - / = ? ^ _ ` { | } ~`, so the apostrophe is exactly as legal as `+` (a dot is also allowed, but only between such characters). A regex built from `\\w` (letters, digits, underscore) plus a hand-picked handful of extras will inevitably miss some of that list, and the apostrophe is a costly one to miss because it appears in real surnames such as O'Brien and D'Angelo, so the regex rejects real customers rather than malformed input.","affects":["python","javascript","any email validator built from a hand-picked character class instead of RFC 5322's atext"],"repros":[{"language":"javascript","status":"verified","code":"const common = /^[\\w.+-]+@[\\w-]+\\.[a-zA-Z]{2,}$/;\nconsole.log(common.test(\"obrien@example.com\"));\nconsole.log(common.test(\"o'brien@example.com\"));\nconsole.log(common.test(\"user+tag@example.com\"));\n","expected_output":"true\nfalse\ntrue","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"import re\ncommon = re.compile(r\"^[\\w.+-]+@[\\w-]+\\.[a-zA-Z]{2,}$\")\nprint(bool(common.match(\"obrien@example.com\")))\nprint(bool(common.match(\"o'brien@example.com\")))\nprint(bool(common.match(\"user+tag@example.com\")))\n","expected_output":"True\nFalse\nTrue","verified_with":"Python 3.14.2"}],"fix":"Don't hand-roll an email-syntax regex. Validate only loosely for form UX (e.g. \"contains exactly one @, with non-whitespace on both sides\") and rely on the actual downstream proof that matters - whether sending a confirmation email to the address succeeds - rather than a regex meant to approximate RFC 5322's full grammar.","related":[],"sources":[{"title":"RFC 5322: Internet Message Format, Section 3.2.3 (Atom / atext)","url":"https://www.rfc-editor.org/rfc/rfc5322#section-3.2.3"},{"title":"RFC 5321: Simple Mail Transfer Protocol, Section 4.1.2 (Mailbox local-part)","url":"https://www.rfc-editor.org/rfc/rfc5321#section-4.1.2"}],"tags":["email","validation","forms","parsing","names"],"test_cases":[{"inputs":["o'brien@example.com"],"relation":"returns","expected":true,"note":"their_is_valid_email(address) should accept this RFC-5322-legal address rather than rejecting it for containing an apostrophe."},{"inputs":["user+tag@example.com"],"relation":"returns","expected":true,"note":"Plus-addressing is also legal atext and should be accepted."}]},{"id":"json-big-integer-precision-loss","title":"JSON.parse silently changes a 17-digit integer ID to a different number","category":"parsing","assumption":"Parsing a JSON number that looks like an integer gives back that exact integer, as long as the JSON text itself is well-formed.","input":{"display":"{\"id\": 9007199254740993}","description":"A large numeric ID - a Snowflake-style ID, a 64-bit database primary key, a big serial number - serialized as a JSON number (not a string) by a backend and consumed by a JavaScript client."},"what_goes_wrong":"`JSON.parse('{\"id\": 9007199254740993}').id` returns `9007199254740992` - one less than the actual ID - and re-serializing it with `JSON.stringify` writes back the wrong, rounded value. Python's `json.loads` on the identical text keeps the exact integer.","why":"JavaScript's JSON implementation stores every number as the same IEEE-754 double that all JS Numbers use, and doubles can only represent integers exactly up to 2**53 (9,007,199,254,740,992); 9007199254740993 is one past that boundary and rounds to the nearest representable double, which happens to be the even number below it. Python's `json` module parses an integer-looking JSON number into a native arbitrary-precision `int`, which has no such ceiling, so it round-trips exactly.","affects":["javascript"],"repros":[{"language":"javascript","status":"verified","code":"const o = JSON.parse('{\"id\": 9007199254740993}');\nconsole.log(o.id);\nconsole.log(o.id === 9007199254740993);\nconsole.log(JSON.stringify(o));\n","expected_output":"9007199254740992\ntrue\n{\"id\":9007199254740992}","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"import json\no = json.loads('{\"id\": 9007199254740993}')\nprint(o[\"id\"])\nprint(o[\"id\"] == 9007199254740993)\n","expected_output":"9007199254740993\nTrue","verified_with":"Python 3.14.2"}],"fix":"Serialize any integer that might exceed 2**53 as a JSON string, not a JSON number, and parse it on the JS side with `BigInt(str)` rather than `JSON.parse`'s default Number conversion. Many JSON APIs for 64-bit IDs (e.g. Twitter/X's `id_str`) already do this for exactly this reason.","related":["max-safe-integer-plus-two"],"sources":[{"title":"MDN: Number.MAX_SAFE_INTEGER","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER"},{"title":"MDN: JSON.parse()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse"},{"title":"Python docs: json - JSON encoder and decoder","url":"https://docs.python.org/3/library/json.html"}],"tags":["json","parsing","id","validation"],"test_cases":[{"inputs":["{\"id\": 9007199254740993}"],"relation":"returns","expected":{"id":9007199254740992},"note":"their_parse(json_text) must preserve this exact integer id - if it goes through JS's Number type anywhere in the pipeline, it will silently become 9007199254740992."}]},{"id":"json-duplicate-keys-last-wins","title":"A JSON object with the same key twice parses fine - and silently keeps only the last one","category":"parsing","assumption":"A JSON parser rejects (or at least warns about) an object literal that repeats a key, the way a careful reviewer would.","input":{"display":"{\"a\":1,\"a\":2}","description":"A JSON payload assembled by string concatenation, a buggy serializer, or a merge of two partial objects that happens to repeat a key - e.g. a webhook payload built from two code paths that both set `\"status\"`."},"what_goes_wrong":"Both Python's `json.loads` and JavaScript's `JSON.parse` accept `{\"a\":1,\"a\":2}` without error and return `{\"a\": 2}` - the first value is discarded with no warning, exception, or trace that the input ever had a conflict.","why":"The JSON grammar (RFC 8259 section 4) does not forbid duplicate names in an object: names SHOULD be unique, and when they aren't, the RFC says the behaviour of receiving software is unpredictable. The two languages then pin it down themselves. ECMA-262 specifies that `JSON.parse` overwrites a lexically earlier value with a later one for the same key, so last-wins is guaranteed in every conforming JavaScript engine. Python's `json` module documents the same choice: it does not raise and keeps only the last name/value pair. Other parsers in other languages may keep the first value or reject the document, which is how two services can read different values from the same payload.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import json\nprint(json.loads('{\"a\":1,\"a\":2}'))\n","expected_output":"{'a': 2}","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"console.log(JSON.parse('{\"a\":1,\"a\":2}'));\n","expected_output":"{ a: 2 }","verified_with":"Node.js v22.22.2"}],"fix":"Detect duplicates explicitly where they matter. In Python, pass `object_pairs_hook` to `json.loads` and raise if a key repeats; in JavaScript, `JSON.parse` cannot report duplicates, so use a streaming/tokenizing parser that can, or reject them at the producer. Don't assume a successfully-parsed object had exactly the keys the sender wrote.","related":[],"sources":[{"title":"RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format, Section 4","url":"https://www.rfc-editor.org/rfc/rfc8259#section-4"},{"title":"Python docs: json - JSON encoder and decoder","url":"https://docs.python.org/3/library/json.html"},{"title":"ECMA-262: JSON.parse","url":"https://tc39.es/ecma262/#sec-json.parse"}],"tags":["json","parsing","validation","security"],"test_cases":[{"inputs":["{\"a\":1,\"a\":2}"],"relation":"returns","expected":{"a":2},"note":"their_parse(json_text) on an object with a repeated key returns the last value under standard parsers - if two upstream systems disagree about which value should win, catch this before trusting the parsed result."}]},{"id":"json-lone-surrogate-roundtrip","title":"JSON.parse happily accepts a string with an unpaired surrogate - and converting it to UTF-8 corrupts or crashes later","category":"parsing","assumption":"If JSON.parse (or json.loads) accepts a string without error, that string is well-formed Unicode text that any later encoding step will handle safely.","input":{"display":"\"\\ud800\" (a lone UTF-16 high surrogate, with no matching low surrogate)","description":"A string field corrupted upstream - a truncated emoji or CJK character cut exactly between its two surrogate halves, or data copied out of a UTF-16 source (e.g. a Windows filename or a Java/C# string) that already contained an unpaired surrogate - serialized into a JSON payload via a \\u escape."},"what_goes_wrong":"Both `JSON.parse` and Python's `json.loads` accept the escape `\\ud800` and hand back a string containing that single unpaired surrogate, with no error. The corruption surfaces later and differently in each language: encoding that Python string to UTF-8 raises `UnicodeEncodeError`, while in Node, `Buffer.from(s, \"utf8\")` silently replaces the lone surrogate with the U+FFFD replacement character - no exception, no warning, just a quietly different byte sequence than the one sent.","why":"JSON strings are defined over Unicode code points via `\\uXXXX` escapes, and RFC 8259 section 8.2 explicitly notes that the grammar allows an escape for a lone surrogate, leaving the behaviour of receiving software unpredictable - a parser that checks syntax is not required to check that the resulting text is well-formed Unicode. JavaScript strings are UTF-16 code unit sequences and can hold an unpaired surrogate without complaint, so `JSON.parse`/`JSON.stringify` round-trip it fine as a JS value. Python `str` objects are sequences of Unicode code points (including surrogates, which Python represents but which are not valid scalar values), so `json.loads` accepts the escape too - the failure only appears once something tries to encode that code point as actual UTF-8 bytes, because UTF-8 has no legal encoding for a surrogate on its own.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import json\ns = json.loads('\"\\\\ud800\"')\nprint(len(s), hex(ord(s)))\ntry:\n s.encode(\"utf-8\")\nexcept UnicodeEncodeError as e:\n print(\"UnicodeEncodeError:\", e)\n","expected_output":"1 0xd800\nUnicodeEncodeError: 'utf-8' codec can't encode character '\\ud800' in position 0: surrogates not allowed","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const s = JSON.parse('\"\\\\ud800\"');\nconsole.log(s.length, s.charCodeAt(0).toString(16));\nconsole.log(JSON.stringify(s));\nconst buf = Buffer.from(s, \"utf8\");\nconsole.log(buf.toString(\"hex\"), buf.length);\n","expected_output":"1 d800\n\"\\ud800\"\nefbfbd 3","verified_with":"Node.js v22.22.2"}],"fix":"Check that decoded JSON strings are well-formed Unicode before re-encoding them: in Python, `s.encode(\"utf-8\")` (strict) raises on a lone surrogate; in JavaScript, `s.isWellFormed()` (Node 20+, ES2024) returns false for it, and `s.toWellFormed()` substitutes U+FFFD explicitly. Do this before `Buffer.from(s, \"utf8\")`, which substitutes silently.","related":[],"sources":[{"title":"RFC 8259 section 8.2: Unicode characters (unpaired surrogates)","url":"https://www.rfc-editor.org/rfc/rfc8259#section-8.2"},{"title":"Unicode Standard Annex #15 glossary: Unpaired Surrogate","url":"https://www.unicode.org/glossary/#unpaired_surrogate"}],"tags":["json","parsing","validation","security","i18n"],"test_cases":[{"inputs":["\"\\ud800\""],"relation":"no_crash","note":"their_parse_json(text) may legitimately accept this string, but whatever encodes the result to UTF-8 downstream must handle (reject or substitute) an unpaired surrogate rather than silently corrupting it."}]},{"id":"json-nan-infinity-not-valid-json","title":"Python's json.dumps writes NaN and Infinity, which are not JSON - json.loads reads them back, JSON.parse throws","category":"parsing","assumption":"Whatever json.dumps() produces is, by definition, valid JSON that any JSON parser can read back.","input":{"display":"json.dumps(float(\"nan\")) and json.dumps(float(\"inf\"))","description":"A computed statistic, average, or ratio that happens to divide by zero or otherwise become NaN/Infinity, serialized directly into an API response or log line without checking first."},"what_goes_wrong":"`json.dumps(float(\"nan\"))` returns the string `\"NaN\"` and `json.dumps(float(\"inf\"))` returns `\"Infinity\"` - neither token exists in the JSON grammar, so a strict JSON parser (including JavaScript's own `JSON.parse`) throws a syntax error reading data that Python itself happily produced and sent.","why":"The JSON spec (RFC 8259) defines numbers using only digits, an optional sign, decimal point, and exponent - `NaN` and `Infinity` are not part of that grammar. Python's `json.dumps` defaults to `allow_nan=True`, which intentionally emits the non-standard literals `NaN`, `Infinity`, and `-Infinity` for those float values rather than raising an error, prioritizing round-tripping Python floats over strict JSON compliance. `json.loads` on the Python side will happily read its own non-standard output back, which hides the incompatibility until something stricter - a browser's `JSON.parse`, another language's parser, a schema validator - receives it.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import json\ns_nan = json.dumps(float(\"nan\"))\ns_inf = json.dumps(float(\"inf\"))\nprint(s_nan, s_inf)\nprint(json.loads(s_nan))\ntry:\n json.dumps(float(\"nan\"), allow_nan=False)\nexcept ValueError as e:\n print(\"ValueError:\", e)\n","expected_output":"NaN Infinity\nnan\nValueError: Out of range float values are not JSON compliant: nan","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"try {\n JSON.parse(\"NaN\");\n} catch (e) {\n console.log(\"THROWS:\", e.message);\n}\ntry {\n JSON.parse(\"Infinity\");\n} catch (e) {\n console.log(\"THROWS:\", e.message);\n}\nconsole.log(JSON.stringify(NaN), JSON.stringify(Infinity));\n","expected_output":"THROWS: \"NaN\" is not valid JSON\nTHROWS: \"Infinity\" is not valid JSON\nnull null","verified_with":"Node.js v22.22.2"}],"fix":"Call `json.dumps(value, allow_nan=False)` so Python raises immediately instead of emitting non-standard JSON, and decide explicitly how to represent a NaN/Infinity result (e.g. `null`, a sentinel string, or omit the field) before it reaches a serializer. Note JavaScript's own `JSON.stringify` already does the opposite - it silently turns `NaN`/`Infinity` into `null` rather than erroring - so the two languages fail in different directions here.","related":["json-big-integer-precision-loss"],"sources":[{"title":"RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format","url":"https://www.rfc-editor.org/rfc/rfc8259"},{"title":"Python docs: json - JSON encoder and decoder (allow_nan)","url":"https://docs.python.org/3/library/json.html#json.dump"},{"title":"MDN: JSON.stringify()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify"}],"tags":["json","parsing","validation","money"],"test_cases":[]},{"id":"url-backslash-treated-as-slash-whatwg","title":"A backslash in an http(s) URL is silently treated as a forward slash by the browser's URL parser, but not by Python","category":"parsing","assumption":"A URL parser only treats \"/\" as a path separator, so a backslash in a URL string stays part of the host or path text it was written in.","input":{"display":"http://example.com\\evil.com/path","description":"A URL built by concatenating a trusted base domain with user-controlled text that includes a backslash - deliberately, as an SSRF or open-redirect bypass attempt, or accidentally, from a Windows-style path pasted into a URL field."},"what_goes_wrong":"The WHATWG `URL` parser (used by every modern browser and by Node's global `URL`) treats the backslash exactly like a forward slash for `http`/`https` URLs, splitting the host at it: `new URL(\"http://example.com\\\\evil.com/path\").hostname` is `\"example.com\"` with pathname `\"/evil.com/path\"`. Python's `urllib.parse.urlparse` does not special-case backslashes at all, so it keeps `\"example.com\\\\evil.com\"` as one literal hostname string. The same input string resolves to a completely different host depending on which parser read it - a host-based allowlist check written against one parser's output can disagree with the HTTP client (or browser) that actually makes the request using the other's interpretation.","why":"The WHATWG URL Standard treats `\\` as equivalent to `/` only for \"special\" schemes (http, https, ws, wss, ftp, file), recording it as a validation error but continuing, because browsers historically normalized backslashes for compatibility with sloppy real-world URLs. For a non-special scheme the backslash is not a separator: `new URL(\"foo://example.com/a\\\\b\").pathname` keeps it as `/a\\\\b`. Python's `urllib.parse` follows the RFC 3986 generic syntax, in which a backslash is not a legal URI character at all - but `urlparse` does no character validation, so it simply leaves the backslash inside whatever component it lands in. Neither parser is misbehaving by its own spec; they just answer \"what host is this?\" differently for the same string.","affects":["javascript","any WHATWG-URL parser (browsers, Node's URL)","python (as the disagreeing side: urllib.parse keeps the backslash in the host)"],"repros":[{"language":"javascript","status":"verified","code":"const u = new URL(\"http://example.com\\\\evil.com/path\");\nconsole.log(u.hostname, u.pathname);\n// only special schemes: a non-special scheme keeps the backslash in the path\nconst f = new URL(\"foo://example.com/a\\\\b\");\nconst h = new URL(\"http://example.com/a\\\\b\");\nconsole.log(f.pathname, h.pathname);\n","expected_output":"example.com /evil.com/path\n/a\\b /a/b","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"from urllib.parse import urlparse\nu = urlparse(\"http://example.com\\\\evil.com/path\")\nprint(u.hostname, u.path)\n","expected_output":"example.com\\evil.com /path","verified_with":"Python 3.14.2"}],"fix":"Never build a trust decision (an SSRF allowlist, a same-origin check) against a hostname string parsed by one library if the request will actually be issued by a different URL-parsing implementation (a browser, a different HTTP client, another language's service). Normalize backslashes to forward slashes (or reject URLs containing them) before any parsing happens, consistently across every component that will touch the URL.","related":["url-userinfo-trusted-looking-host"],"sources":[{"title":"WHATWG URL Standard: special schemes and path-segment separators","url":"https://url.spec.whatwg.org/#url-parsing"},{"title":"Python docs: urllib.parse","url":"https://docs.python.org/3/library/urllib.parse.html"}],"tags":["url","parsing","security","validation"],"test_cases":[{"inputs":["http://example.com\\evil.com/path"],"relation":"no_crash","note":"their_extract_hostname(url) must be checked against the SAME parser that will actually dereference the URL - a hostname check written against one URL parser can be bypassed by a client that resolves backslashes differently."}]},{"id":"url-urlparse-never-raises-on-garbage","title":"Python's urlparse doesn't raise on garbage - \"not a url\" parses \"successfully\" into an empty scheme and host","category":"parsing","assumption":"If a string isn't a valid URL, the URL-parsing function you call will raise an error, so \"it didn't throw\" is good evidence the input was a real URL.","input":{"display":"not a url","description":"User-submitted text in a field that is supposed to be a URL (a profile link, a webhook target, a redirect destination), validated with `urlparse` and a check like `if result.netloc: ...` or simply `try: urlparse(x) except: reject`."},"what_goes_wrong":"`urlparse(\"not a url\")` does not raise - it returns a `ParseResult` with empty `scheme` and `netloc` and the whole input shoved into `.path`. JavaScript's `new URL(\"not a url\")` does the opposite: it throws `TypeError: Invalid URL` immediately, because the WHATWG algorithm requires an absolute URL (or a base to resolve against) and fails loudly otherwise.","why":"Python's `urllib.parse.urlparse` implements RFC 3986's generic syntax as a permissive, almost mechanical split on delimiter characters (`://`, `@`, `:`, `/`, `?`, `#`) - if the expected delimiters for scheme/netloc aren't present, it doesn't fail, it just leaves those fields empty and puts everything into whichever component comes last in the fallback order (`path`). It was designed to parse URI *references* (which may be relative, schemeless fragments) permissively, not to validate that a string is a complete, dereferenceable URL. The WHATWG `URL` constructor is deliberately the opposite: it exists specifically to parse (and validate) a resolvable URL, so it throws on anything that doesn't resolve to one. (It is not literally exception-free: an unbalanced IPv6 bracket such as `http://[::1` raises ValueError, and reading `.port` raises for an out-of-range port - but ordinary non-URL text goes straight through.)","affects":["python"],"repros":[{"language":"python","status":"verified","code":"from urllib.parse import urlparse\nr = urlparse(\"not a url\")\nprint(r)\nprint(bool(r.scheme), bool(r.netloc))\n","expected_output":"ParseResult(scheme='', netloc='', path='not a url', params='', query='', fragment='')\nFalse False","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"try {\n const u = new URL(\"not a url\");\n console.log(\"parsed\", u.href);\n} catch (e) {\n console.log(\"THROWS:\", e.message);\n}\n","expected_output":"THROWS: Invalid URL","verified_with":"Node.js v22.22.2"}],"fix":"Never treat \"urlparse didn't raise\" as proof of a valid URL in Python. Explicitly check that both `scheme` is one of the schemes you accept (e.g. `http`/`https`) and `netloc` is non-empty, or use a stricter validator before trusting the result.","related":["url-backslash-treated-as-slash-whatwg","url-userinfo-trusted-looking-host"],"sources":[{"title":"Python docs: urllib.parse","url":"https://docs.python.org/3/library/urllib.parse.html"},{"title":"WHATWG URL Standard: URL parsing failures","url":"https://url.spec.whatwg.org/#url-parsing"}],"tags":["url","parsing","validation","forms","security"],"test_cases":[{"inputs":["not a url"],"relation":"no_crash","note":"their_is_valid_url(text) must explicitly reject this - checking only whether urlparse() raised is not enough, since urlparse never raises on this input."}]},{"id":"url-userinfo-trusted-looking-host","title":"\"http://trusted.com@evil.com/\" goes to evil.com, not trusted.com - and both languages' real parsers already know that","category":"parsing","assumption":"A URL string that starts with (or contains) \"trusted.com\" points at trusted.com.","input":{"display":"http://trusted.com@evil.com/","description":"A link in a phishing email, a redirect parameter, or a webhook target URL that a naive check validates by looking for a trusted domain name somewhere in the string - `url.startsWith(\"http://trusted.com\")` or a regex containing `trusted\\.com`."},"what_goes_wrong":"A substring/prefix check for \"trusted.com\" passes on `http://trusted.com@evil.com/`, but the browser (or any real HTTP client) actually connects to `evil.com` - `trusted.com` here is just userinfo (a username with no password), not the host, and both a proper WHATWG `URL` parse and Python's `urlparse` agree the real host is `evil.com`.","why":"RFC 3986's authority component has the shape `userinfo@host:port` - everything before an `@` in the authority is credentials, not part of the hostname, and both a real URL parser and the browser's address bar logic strip it before deciding where to connect. A regex or `startsWith` check that only looks for the trusted domain's text anywhere near the front of the string has no concept of URL grammar at all, so it is fooled by a syntax feature (`@`) that every conformant parser already understands correctly. This is not a parser disagreement - it's a gap between \"looks like it mentions the right domain\" and \"actually parses to the right host\".","affects":["python","javascript","any string-based (non-parsing) URL trust check"],"repros":[{"language":"javascript","status":"verified","code":"const u = new URL(\"http://trusted.com@evil.com/\");\nconsole.log(u.hostname, u.username);\nconsole.log(\"http://trusted.com@evil.com/\".startsWith(\"http://trusted.com\"));\n","expected_output":"evil.com trusted.com\ntrue","verified_with":"Node.js v22.22.2"},{"language":"python","status":"verified","code":"from urllib.parse import urlparse\nu = urlparse(\"http://trusted.com@evil.com/\")\nprint(u.hostname, u.username)\nprint(\"http://trusted.com@evil.com/\".startswith(\"http://trusted.com\"))\n","expected_output":"evil.com trusted.com\nTrue","verified_with":"Python 3.14.2"}],"fix":"Never validate a URL's target by substring or prefix matching on the raw string. Parse the URL and compare the parsed `.hostname` against an allowlist of exact hostnames (or a proper suffix match on the hostname component specifically, e.g. ends with `.trusted.com`), never against the whole URL text.","related":["url-backslash-treated-as-slash-whatwg"],"sources":[{"title":"RFC 3986: Uniform Resource Identifier (URI): Generic Syntax, Section 3.2","url":"https://www.rfc-editor.org/rfc/rfc3986#section-3.2"},{"title":"WHATWG URL Standard","url":"https://url.spec.whatwg.org/"}],"tags":["url","parsing","security","validation"],"test_cases":[{"inputs":["http://trusted.com@evil.com/"],"relation":"no_crash","note":"their_is_trusted_url(url) must parse the URL and check the hostname field - not check whether the string contains or starts with the trusted domain's text - or this input will pass a check it should fail."}]},{"id":"accented-letters-sort-after-z","title":"\"äpple\" sorts after \"zebra\" with a default sort","category":"sorting","assumption":"An accented letter sorts next to its unaccented counterpart — \"äpple\" belongs near \"apple\", not off at the end of the alphabet.","input":{"display":"[\"zebra\", \"äpple\", \"apple\"]","description":"A product or name list containing a word with a diacritic, sorted with the default (non-locale-aware) string comparison."},"what_goes_wrong":"The default sort returns [\"apple\", \"zebra\", \"äpple\"] — \"äpple\" lands after \"zebra\", not next to \"apple\" where a human alphabetizing by the base letter would put it.","why":"\"ä\" is U+00E4, a higher value than \"z\" (U+007A): the Latin-1 Supplement block, where most Western European accented letters live, comes entirely after the ASCII lowercase letters. Python's default string comparison orders by code point and JavaScript's by UTF-16 code unit (the same thing for these characters), so neither knows that \"ä\" is a variant of \"a\" in most European alphabets. Only a locale-aware collator knows that relationship.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"words = [\"zebra\", \"\\u00e4pple\", \"apple\"]\nprint(sorted(words))\n","expected_output":"['apple', 'zebra', 'äpple']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const words = [\"zebra\", \"\\u00e4pple\", \"apple\"];\nconsole.log(words.slice().sort());\nconsole.log(words.slice().sort((a, b) => a.localeCompare(b)));\n","expected_output":"[ 'apple', 'zebra', 'äpple' ]\n[ 'apple', 'äpple', 'zebra' ]","verified_with":"Node.js v22.22.2"}],"fix":"Use a locale-aware comparator for any text a human will read: Intl.Collator or String.prototype.localeCompare() in JavaScript. Python's standard library has no built-in Unicode collation; it requires either the system locale module (environment-dependent) or a third-party ICU binding.","related":["byte-order-caps-before-lowercase","locale-collation-de-vs-sv"],"sources":[{"title":"Unicode Standard Annex #10: Unicode Collation Algorithm","url":"https://www.unicode.org/reports/tr10/"},{"title":"MDN: String.prototype.localeCompare()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare"}],"tags":["sorting","comparison","names","i18n","display"],"test_cases":[{"inputs":["zebra","äpple"],"relation":"returns","expected":false,"note":"their_function(a, b) as a human-order \"a sorts before b\" test should return false: äpple belongs before zebra. Raw `a < b` returns true here, which is the trap."}]},{"id":"byte-order-caps-before-lowercase","title":"\"Zebra\" sorts before \"apple\" by default","category":"sorting","assumption":"Sorting a list of names or words alphabetically puts them in the order a person would expect: apple, then zebra.","input":{"display":"[\"Zebra\", \"apple\", \"Mango\", \"banana\"]","description":"A list of user-entered names, tags, or product titles with inconsistent capitalization, sorted with the language's default string comparison for a dropdown or report."},"what_goes_wrong":"The default sort returns [\"Mango\", \"Zebra\", \"apple\", \"banana\"] — every capitalized word sorts before every lowercase word, so \"Zebra\" ends up before \"apple\" even though no human alphabetizing this list would ever do that.","why":"Python's default string comparison orders characters by Unicode code point and JavaScript's by UTF-16 code unit; for ASCII letters both are the same as byte order. The uppercase letters A-Z (U+0041-U+005A) all come before the lowercase letters a-z (U+0061-U+007A), so a word starting with a capital sorts before every word starting with a lowercase ASCII letter, because the comparison is settled at the first character where the two strings differ and here that is the first letter.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"words = [\"Zebra\", \"apple\", \"Mango\", \"banana\"]\nprint(sorted(words))\nprint(\"Zebra\" < \"apple\")\n","expected_output":"['Mango', 'Zebra', 'apple', 'banana']\nTrue","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const words = [\"Zebra\", \"apple\", \"Mango\", \"banana\"];\nconsole.log(words.slice().sort());\nconsole.log(\"Zebra\" < \"apple\");\n","expected_output":"[ 'Mango', 'Zebra', 'apple', 'banana' ]\ntrue","verified_with":"Node.js v22.22.2"}],"fix":"Sort case-insensitively for anything a human will read: key on the lowercased (or casefolded) string, or use a locale-aware comparator (Intl.Collator in JS; Python has no case-insensitive built-in comparator, so pass key=str.casefold to sorted()).","related":["accented-letters-sort-after-z","case-insensitive-sort-lower-vs-casefold"],"sources":[{"title":"Unicode Standard: Code Charts, Basic Latin block","url":"https://www.unicode.org/charts/PDF/U0000.pdf"},{"title":"Python docs: Comparisons (str ordering is by code point)","url":"https://docs.python.org/3/reference/expressions.html#comparisons"},{"title":"ECMA-262: Array.prototype.sort, default SortCompare","url":"https://tc39.es/ecma262/#sec-array.prototype.sort"}],"tags":["sorting","comparison","names","display"],"test_cases":[{"inputs":["Zebra","apple"],"relation":"returns","expected":false,"note":"their_function(a, b) as a display-order \"a sorts before b\" test should return false: apple belongs before Zebra. Raw `a < b` returns true here, which is the trap."}]},{"id":"case-insensitive-sort-lower-vs-casefold","title":"Switching a sort key from .lower() to .casefold() changes the order of unrelated-looking rows","category":"sorting","assumption":"lower() and casefold() are interchangeable ways to spell \"case-insensitive sort key\" — swapping one for the other can only change case-sensitivity bugs, never the actual ordering of already-matching rows.","input":{"display":"[\"Stra\\u00dfe\", \"strasse\", \"Strasse\"] sorted with key=str.lower vs key=str.casefold","description":"A list of street-name or username variants containing German ß, sorted case-insensitively for display or dedup grouping."},"what_goes_wrong":"sorted(words, key=str.lower) returns [\"strasse\", \"Strasse\", \"Stra\\u00dfe\"]: \"Stra\\u00dfe\" sorts last, after both ASCII spellings. sorted(words, key=str.casefold) returns [\"Stra\\u00dfe\", \"strasse\", \"Strasse\"]: \"Stra\\u00dfe\" now sorts FIRST. The three rows are the same; only the key function changed. JavaScript shows the same flip between a toLowerCase() comparator and an Intl.Collator with sensitivity \"base\".","why":"str.lower() leaves ß as ß, so the key for \"Straße\" is \"straße\", which differs from \"strasse\" at the fifth character; ß (U+00DF) is a higher code point than \"s\", so it sorts after both ASCII spellings. str.casefold() applies Unicode full case folding (CaseFolding.txt), which maps ß to \"ss\", so the key for \"Straße\" becomes \"strasse\", identical to the other two rows' keys. With all three keys tied, Python's stable sort keeps the rows in input order, which is why \"Straße\" (first in the input) comes out first instead of last. JavaScript has no casefold(), but the same shift happens when a comparator moves from toLowerCase() to an Intl.Collator with sensitivity \"base\", which also treats ß and ss as equal.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"words = [\"Stra\\u00dfe\", \"strasse\", \"Strasse\"]\nprint(sorted(words, key=str.lower))\nprint(sorted(words, key=str.casefold))\n","expected_output":"['strasse', 'Strasse', 'Straße']\n['Straße', 'strasse', 'Strasse']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const words = [\"Stra\\u00dfe\", \"strasse\", \"Strasse\"];\nconst byLower = (a, b) => { const x = a.toLowerCase(), y = b.toLowerCase(); return x < y ? -1 : x > y ? 1 : 0; };\nconsole.log(words.slice().sort(byLower));\nconsole.log(words.slice().sort(new Intl.Collator(\"en\", { sensitivity: \"base\" }).compare));\n","expected_output":"[ 'strasse', 'Strasse', 'Straße' ]\n[ 'Straße', 'strasse', 'Strasse' ]","verified_with":"Node.js v22.22.2"}],"fix":"Decide deliberately whether your case-insensitive key should treat folding equivalences like \\u00df\\u2192ss as ties, and know that changing the folding function (lower() vs casefold() in Python, toLowerCase() vs a base-sensitivity Intl.Collator in JavaScript) can change sort order, not just equality. If the final order must be deterministic, add a tiebreaker (for example the original string) after the case-insensitive key: key=lambda s: (s.casefold(), s).","related":["german-eszett-case-fold","byte-order-caps-before-lowercase"],"sources":[{"title":"Python docs: str.casefold","url":"https://docs.python.org/3/library/stdtypes.html#str.casefold"},{"title":"Unicode Standard: CaseFolding.txt","url":"https://www.unicode.org/Public/UCD/latest/ucd/CaseFolding.txt"},{"title":"MDN: Intl.Collator() constructor, sensitivity option","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator"}],"tags":["sorting","comparison","names","i18n","normalization"],"test_cases":[]},{"id":"comparator-returns-boolean-js","title":"arr.sort((a, b) => a > b) can leave an array completely unsorted","category":"sorting","assumption":"A sort comparator just needs to return something truthy when the first argument should come after the second.","input":{"display":"[5, 3, 8, 1, 9, 2, 7, 4, 6].sort((a, b) => a > b)","description":"A comparator written the way a boolean-returning comparison reads naturally in prose (\"is a bigger than b\"), instead of returning a signed number."},"what_goes_wrong":"In Node.js (V8), sort((a, b) => a > b) on this 9-element array returns it unchanged, [5, 3, 8, 1, 9, 2, 7, 4, 6], while the numeric comparator sort((a, b) => a - b) returns it fully sorted. No error, no warning.","why":"A sort comparator must return a number: negative if a comes first, positive if b comes first, zero if either order is fine. A boolean is converted with ToNumber to 1 or 0, so this comparator can never say \"a comes first\". That makes it inconsistent (it reports 1 and 5 as equal but 5 and 1 as ordered), and for an inconsistent comparator the ECMAScript spec says the resulting order is implementation-defined, so what you get depends on the engine. V8's TimSort (Node.js 11+, Chrome 70+) only moves an element after a negative result; this comparator never returns one, so in V8 the whole input is treated as one already-sorted run and comes back in its original order. Older V8 versions and other engines use different algorithms and may return a partly sorted array instead, which is harder to notice in testing.","affects":["javascript"],"repros":[{"language":"python","status":"verified","code":"data = [5, 3, 8, 1, 9, 2, 7, 4, 6]\nprint(sorted(data))\n","expected_output":"[1, 2, 3, 4, 5, 6, 7, 8, 9]","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const data = [5, 3, 8, 1, 9, 2, 7, 4, 6];\nconsole.log(data.slice().sort((a, b) => a > b));\nconsole.log(data.slice().sort((a, b) => a - b));\n","expected_output":"[\n 5, 3, 8, 1, 9,\n 2, 7, 4, 6\n]\n[\n 1, 2, 3, 4, 5,\n 6, 7, 8, 9\n]","verified_with":"Node.js v22.22.2"}],"fix":"Always return a signed number from a JavaScript sort comparator (a - b for numbers), never a boolean. Python's sorted()/list.sort() sidestep this entirely — Python 3 removed the old cmp= parameter and only accepts a key= function, so there is no comparator-return-type footgun to have.","related":["nan-in-sort"],"sources":[{"title":"ECMA-262: Array.prototype.sort, comparator requirements","url":"https://tc39.es/ecma262/#sec-array.prototype.sort"},{"title":"MDN: Array.prototype.sort() — description of compareFn","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#description"}],"tags":["sorting","comparison","pagination","display"],"test_cases":[]},{"id":"js-array-sort-stringifies-numbers","title":"[10, 9, 1].sort() returns [1, 10, 9]","category":"sorting","assumption":"Calling .sort() on an array of numbers sorts them numerically.","input":{"display":"[10, 9, 1].sort()","description":"Sorting a plain JavaScript array of numbers (scores, counts, prices) without passing a comparator — the single most common sort call in JavaScript."},"what_goes_wrong":"[10, 9, 1].sort() returns [1, 10, 9] — not [1, 9, 10]. 10 sorts before 9.","why":"When no comparator is supplied, the ECMAScript spec has Array.prototype.sort() convert each element to a string and compare the strings (undefined values are set aside and placed at the end). It is never numeric unless you pass a comparator. As strings, \"10\" < \"9\" is true, because \"1\" has a lower code unit than \"9\" and string comparison stops at the first differing character, so 10 sorts before 9. Python's sorted() compares the numbers themselves, so this failure does not exist there.","affects":["javascript"],"repros":[{"language":"python","status":"verified","code":"print(sorted([10, 9, 1]))\n","expected_output":"[1, 9, 10]","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"console.log([10, 9, 1].sort());\nconsole.log([10, 9, 1].sort((a, b) => a - b));\n","expected_output":"[ 1, 10, 9 ]\n[ 1, 9, 10 ]","verified_with":"Node.js v22.22.2"}],"fix":"Always pass an explicit numeric comparator to Array.prototype.sort() when sorting numbers: arr.sort((a, b) => a - b). There is no \"numeric mode\" flag — the comparator is the only way to opt out of string comparison.","related":["natural-sort-breaks-lexicographic","typed-array-sort-is-numeric-by-default"],"sources":[{"title":"ECMA-262: Array.prototype.sort, default SortCompare behavior","url":"https://tc39.es/ecma262/#sec-array.prototype.sort"},{"title":"MDN: Array.prototype.sort()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort"}],"tags":["sorting","comparison","display","pagination"],"test_cases":[{"inputs":[[10,9,1]],"relation":"returns","expected":[1,9,10],"note":"A numeric sort should return [1, 9, 10]. An implementation that calls arr.sort() with no comparator returns [1, 10, 9]."}]},{"id":"locale-collation-de-vs-sv","title":"The same three letters sort in a different order in German than in Swedish","category":"sorting","assumption":"Locale-aware sorting just fixes the \"accents sort wrong\" problem the same way for every language.","input":{"display":"[\"z\", \"\\u00e4\", \"a\"].sort with localeCompare(..., \"de\") vs localeCompare(..., \"sv\")","description":"A single sort function applied to the same three-letter list, parameterized only by a BCP 47 locale tag — e.g. a user-preference-driven \"sort my contacts\" feature."},"what_goes_wrong":"localeCompare with locale \"de\" (German) sorts the list as [\"a\", \"\\u00e4\", \"z\"] — ä treated as a minor variant of a, placed right next to it. localeCompare with locale \"sv\" (Swedish) sorts the exact same list as [\"a\", \"z\", \"\\u00e4\"] — ä treated as its own letter, placed after z. There is no single \"correct\" locale-aware order; it depends entirely on which locale you pass.","why":"German collation in CLDR (the data behind ICU and JavaScript's Intl) uses the Unicode default ordering with no tailoring: ä is an a with an accent difference, so it sorts right next to a, which matches German dictionary order (DIN 5007-1). CLDR also has a German phonebook variant (de-u-co-phonebk) that sorts ä as \"ae\". Swedish treats å, ä and ö as separate letters at the end of the alphabet, after z, and the Swedish CLDR collation tailors them there. Both are correct for their own language; a collator has to be told which one to use through its locale argument, and a wrong or missing locale applies one language's rules to another's text. Python's sorted() in the repro is plain code-point order, which happens to agree with Swedish here only because ä (U+00E4) is numerically after z.","affects":["javascript"],"repros":[{"language":"python","status":"verified","code":"words = [\"z\", \"\\u00e4\", \"a\"]\nprint(sorted(words))\n","expected_output":"['a', 'z', 'ä']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const words = [\"z\", \"\\u00e4\", \"a\"];\nconsole.log(words.slice().sort((a, b) => a.localeCompare(b, \"de\")));\nconsole.log(words.slice().sort((a, b) => a.localeCompare(b, \"sv\")));\nconsole.log(words.slice().sort());\n","expected_output":"[ 'a', 'ä', 'z' ]\n[ 'a', 'z', 'ä' ]\n[ 'a', 'z', 'ä' ]","verified_with":"Node.js v22.22.2"}],"fix":"Always pass the user's actual locale to the collator rather than relying on a default; never assume one European locale's alphabetical order generalizes to another's. JavaScript ships this via Intl.Collator/localeCompare with a locale argument; Python's standard library has no bundled Unicode collation tables, so matching this behavior requires the system's installed locale data (unreliable across machines) or a third-party ICU binding.","related":["accented-letters-sort-after-z"],"sources":[{"title":"Unicode Technical Standard #10: Unicode Collation Algorithm","url":"https://www.unicode.org/reports/tr10/"},{"title":"MDN: Intl.Collator","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator"},{"title":"CLDR collation charts","url":"https://www.unicode.org/cldr/charts/latest/collation/index.html"}],"tags":["sorting","comparison","names","i18n","display"],"test_cases":[]},{"id":"localecompare-numeric-option","title":"There's a one-word fix for natural sort in JavaScript, and it's off by default","category":"sorting","assumption":"Fixing \"file10 sorts before file2\" requires writing your own digit-splitting comparator.","input":{"display":"[\"file10.txt\", \"file2.txt\", \"file1.txt\"].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))","description":"The same filename-sorting problem as the plain natural-sort exhibit, this time looking for the fix rather than the bug."},"what_goes_wrong":"Passing { numeric: true } as an option to localeCompare correctly returns [\"file1.txt\", \"file2.txt\", \"file10.txt\"] with no custom parsing code at all — but it is not the default for localeCompare, not the default for Array.prototype.sort(), and has no equivalent option anywhere in Python's standard library sorted(); Python's locale.strxfrm (the one built-in collation hook Python has) does not implement it either and returns the same wrong lexicographic order as a plain sort.","why":"ECMA-402 (the Intl part of JavaScript) defines a numeric collation option (the Unicode locale extension \"kn\") that makes the collator compare runs of decimal digits by their numeric value instead of character by character, and String.prototype.localeCompare accepts the same options object as Intl.Collator. It is opt-in because it is a tailoring on top of the default Unicode Collation Algorithm ordering, not the default. Python ships no Unicode collation implementation: locale.strxfrm only applies whatever collation the operating system's locale provides, and the common C-library locales have no numeric-run option, so in Python the digit-run comparison has to be hand-written.","affects":["javascript"],"repros":[{"language":"python","status":"verified","code":"import locale\nfiles = [\"file10.txt\", \"file2.txt\", \"file1.txt\"]\nprint(sorted(files, key=locale.strxfrm))\n","expected_output":"['file1.txt', 'file10.txt', 'file2.txt']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const files = [\"file10.txt\", \"file2.txt\", \"file1.txt\"];\nconsole.log(files.slice().sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));\n","expected_output":"[ 'file1.txt', 'file2.txt', 'file10.txt' ]","verified_with":"Node.js v22.22.2"}],"fix":"In JavaScript, pass { numeric: true } to localeCompare (or construct one Intl.Collator with that option and reuse its compare for the whole sort, which is faster). It only treats runs of digits as non-negative integers: signs, decimal points and thousands separators are still compared as characters. In Python, write a natural-sort key that splits each string on digit runs with re.split(r\"(\\d+)\", s) and converts the digit groups to int, since there is no built-in numeric collation to opt into.","related":["natural-sort-breaks-lexicographic"],"sources":[{"title":"ECMA-402: Intl.Collator, the numeric option","url":"https://tc39.es/ecma402/#sec-intl-collator-constructor"},{"title":"MDN: String.prototype.localeCompare()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare"}],"tags":["sorting","comparison","names","display","pagination"],"test_cases":[]},{"id":"nan-in-sort","title":"One NaN can leave the rest of the list unsorted","category":"sorting","assumption":"A NaN in a numeric list just ends up somewhere predictable, and every other value still gets sorted.","input":{"display":"sorted([1, 2, 3, nan]), sorted([nan, 1, 2, 3]) and sorted([3, nan, 1])","description":"A numeric column (a score, a computed ratio, a percentage with a 0/0 somewhere) that can legitimately contain NaN, sorted for a leaderboard or report."},"what_goes_wrong":"Where NaN lands depends on where it started ([1, 2, 3, nan] stays as is, [nan, 1, 2, 3] stays as is), and worse, sorted([3, nan, 1]) returns [3, nan, 1]: the numbers 3 and 1 are left out of order. JavaScript's sort with (a, b) => a - b returned [3, NaN, 1] for the same input in Node.js.","why":"IEEE 754 makes every ordered comparison with NaN false, so to Python's sort NaN looks equal to everything (neither NaN < x nor x < NaN). In JavaScript, a - b is NaN whenever either side is NaN, and the spec turns a NaN comparator result into 0, which also means \"equal\". Equality with everything breaks transitivity: 3 \"equals\" NaN and NaN \"equals\" 1, but 3 is not equal to 1. Sorting algorithms rely on transitivity to avoid comparing every pair, so they can conclude [3, nan, 1] is already in order. Python's sort makes no promise for inconsistent comparisons, and the ECMAScript spec says the order is implementation-defined when the comparator is not consistent.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"nan = float(\"nan\")\nprint(sorted([1, 2, 3, nan]))\nprint(sorted([nan, 1, 2, 3]))\nprint(sorted([3, nan, 1]))\n","expected_output":"[1, 2, 3, nan]\n[nan, 1, 2, 3]\n[3, nan, 1]","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const byNum = (a, b) => a - b;\nconsole.log([3, 1, NaN, 2].sort(byNum));\nconsole.log([NaN, 3, 1, 2].sort(byNum));\nconsole.log([3, NaN, 1].sort(byNum));\n","expected_output":"[ 1, 2, 3, NaN ]\n[ NaN, 1, 2, 3 ]\n[ 3, NaN, 1 ]","verified_with":"Node.js v22.22.2"}],"fix":"Remove or partition NaN before sorting rather than letting it reach the comparison. Decide whether NaN goes first, last, or is excluded, and encode that directly: in Python, key=lambda x: (math.isnan(x), x) puts NaN last; in JavaScript, a comparator that checks Number.isNaN() on both sides before subtracting.","related":["comparator-returns-boolean-js"],"sources":[{"title":"Python docs: Sorting Techniques","url":"https://docs.python.org/3/howto/sorting.html"},{"title":"Python docs: math.isnan","url":"https://docs.python.org/3/library/math.html#math.isnan"},{"title":"ECMAScript spec: SortIndexedProperties (implementation-defined order for inconsistent comparators)","url":"https://tc39.es/ecma262/#sec-sortindexedproperties"},{"title":"ECMAScript spec: CompareArrayElements (NaN result treated as +0)","url":"https://tc39.es/ecma262/#sec-comparearrayelements"}],"tags":["sorting","comparison","display","pagination"],"test_cases":[]},{"id":"natural-sort-breaks-lexicographic","title":"\"file10.txt\" sorts before \"file2.txt\"","category":"sorting","assumption":"Sorting a list of filenames (or any strings with embedded numbers) alphabetically also puts them in numeric order for the number part.","input":{"display":"[\"file10.txt\", \"file2.txt\", \"file1.txt\"]","description":"A directory listing, a set of uploaded files (page-01.jpg, page-02.jpg, ..., page-10.jpg), or any auto-numbered set of names sorted with a plain string comparison."},"what_goes_wrong":"A plain sort returns [\"file1.txt\", \"file10.txt\", \"file2.txt\"] — \"file10.txt\" is placed between \"file1.txt\" and \"file2.txt\", because standard string comparison never looks at the embedded number as a number at all.","why":"String comparison walks the two strings character by character until it finds a difference. \"file1\" and \"file10\" and \"file2\" all share the prefix \"file\"; comparing position by position, \"1\" < \"2\" decides \"file10\" < \"file2\" before the comparison ever reaches the second digit of \"10\" — the strings are compared as sequences of characters, not parsed into a text part and a numeric part the way a human reads them. This is true identically in Python and JavaScript, because both default to the same kind of character-by-character comparison; neither treats a run of digits as a single unit unless you tell it to.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"files = [\"file10.txt\", \"file2.txt\", \"file1.txt\"]\nprint(sorted(files))\n","expected_output":"['file1.txt', 'file10.txt', 'file2.txt']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const files = [\"file10.txt\", \"file2.txt\", \"file1.txt\"];\nconsole.log(files.slice().sort());\n","expected_output":"[ 'file1.txt', 'file10.txt', 'file2.txt' ]","verified_with":"Node.js v22.22.2"}],"fix":"Use a \"natural sort\": split each string into alternating text/number runs and compare the number runs numerically (JavaScript has this built into localeCompare's {numeric: true} option; Python's standard library has no equivalent and needs a hand-written key function, e.g. splitting on digit runs with re.split and converting the digit groups to int).","related":["js-array-sort-stringifies-numbers","localecompare-numeric-option"],"sources":[{"title":"Unicode Technical Standard #10: Unicode Collation Algorithm","url":"https://www.unicode.org/reports/tr10/"},{"title":"Python docs: sorted() / list.sort()","url":"https://docs.python.org/3/library/stdtypes.html#list.sort"}],"tags":["sorting","comparison","names","display","pagination"],"test_cases":[{"inputs":["file10.txt","file2.txt"],"relation":"returns","expected":false,"note":"their_function(a, b) as a natural-order \"a sorts before b\" test should return false: file2 belongs before file10. Plain `a < b` returns true, which is the bug."}]},{"id":"postgres-collation-documented","title":"ORDER BY in Postgres can return a different row order than the exact same query on a different server","category":"sorting","assumption":"ORDER BY on a text column is a property of the SQL and the data, so the same query against the same rows always returns the same order.","input":{"display":"SELECT name FROM users ORDER BY name;","description":"A plain text ORDER BY on a column using the database's default collation, run against two Postgres instances whose cluster was initialized with different default collations (e.g. \"C\" vs \"en_US.utf8\" vs an ICU collation)."},"what_goes_wrong":"The identical query on identical rows returns a different order on a database whose collation is \"C\" (byte order: case-sensitive, uppercase before lowercase, accented letters after z) than on one using an \"en_US\" libc or ICU collation (a, ä, Z interleaved by letter). Restoring or migrating into a database with a different default collation, or upgrading the operating system's locale data under the same collation name, can change ORDER BY results without changing a row of data or a line of SQL.","why":"Postgres orders text using a collation. Each text column has one, which defaults to the database's collation, and a query can override it with COLLATE. The database default is fixed when the database is created, and the actual ordering comes from either the C library's locale data (the libc provider) or ICU (supported since PostgreSQL 10). So the same SQL orders differently in databases with different collations, and even the same collation name, such as en_US.utf8, can order differently after an operating-system upgrade changes the C library's locale data (the glibc 2.28 change is the best-known case, and it also left existing text indexes inconsistent). A pg_dump made without --create does not carry the database's default collation, so restoring it into a database created with a different default changes every un-pinned text sort.","affects":["postgres"],"repros":[{"language":"python","status":"verified","code":"words = [\"a\", \"Z\", \"\\u00e4\"]\nprint(sorted(words))\n","expected_output":"['Z', 'a', 'ä']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const words = [\"a\", \"Z\", \"\\u00e4\"];\nconsole.log(words.slice().sort());\nconsole.log(words.slice().sort((a, b) => a.localeCompare(b, \"en-US\")));\n","expected_output":"[ 'Z', 'a', 'ä' ]\n[ 'a', 'ä', 'Z' ]","verified_with":"Node.js v22.22.2"},{"language":"postgres","status":"documented","note":"SELECT name FROM (VALUES ('a'),('Z'),('ä')) AS t(name) ORDER BY name COLLATE \"C\"; returns Z, a, ä (UTF-8 byte order, matching the plain code-point sorts in the Python/JS repros above). The same query with COLLATE \"en-US-x-icu\" (available when PostgreSQL is built with ICU) returns a, ä, Z, matching the localeCompare repro above. Without a COLLATE clause the result depends on the database's collation, which you can see with SELECT datcollate FROM pg_database WHERE datname = current_database();","source":"https://www.postgresql.org/docs/current/collation.html"}],"fix":"Pin collation explicitly with COLLATE in any ORDER BY whose result order matters across environments (tests comparing row order, pagination cursors, anything exported and diffed). Check the database collation with SELECT datcollate FROM pg_database WHERE datname = current_database();. Treat a restore onto a server with different locale or ICU data, or an OS upgrade that changes glibc, as a behaviour change for every un-pinned text sort, and reindex text indexes after such an upgrade.","related":["accented-letters-sort-after-z","byte-order-caps-before-lowercase"],"sources":[{"title":"PostgreSQL docs: Collation Support","url":"https://www.postgresql.org/docs/current/collation.html"},{"title":"PostgreSQL wiki: Locale data changes","url":"https://wiki.postgresql.org/wiki/Locale_data_changes"},{"title":"PostgreSQL docs: pg_dump (--create)","url":"https://www.postgresql.org/docs/current/app-pgdump.html"}],"tags":["sorting","comparison","database","pagination","i18n"],"test_cases":[]},{"id":"python-cannot-compare-str-int","title":"Python crashes sorting [2, \"10\", 1]; JavaScript quietly reshuffles it","category":"sorting","assumption":"A list with mixed number and numeric-string types is a data-cleanliness smell, not something that changes which language's sort crashes versus which one silently gives you a wrong-looking answer.","input":{"display":"[2, \"10\", 1, \"3\"] — a list mixing actual numbers and numbers-as-strings","description":"A column assembled from two sources that disagree on type — one API returns numeric IDs as JSON numbers, another returns them as strings — merged into one list and sorted without normalizing types first."},"what_goes_wrong":"Python's sorted([2, \"10\", 1, \"3\"]) raises TypeError: Python 3 has no ordering between int and str. JavaScript's [2, \"10\", 1, \"3\"].sort() runs with no error and returns [1, \"10\", 2, \"3\"], an order that is neither numeric nor meaningful, produced silently.","why":"For a < b, Python first tries a.__lt__(b) and, if that returns NotImplemented, the reflected b.__gt__(a). int.__lt__ does not accept a str and str.__gt__ does not accept an int, so both return NotImplemented and Python raises TypeError rather than guess. (Python 2 did order mixed types, by type name; Python 3 removed that.) JavaScript's default Array.prototype.sort() converts every element with ToString before comparing, so 2 becomes \"2\" and the comparison is ordinary string comparison. By the time anything is compared there is no type mismatch left to object to.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"data = [2, \"10\", 1, \"3\"]\ntry:\n sorted(data)\n print(\"no error\")\nexcept TypeError:\n print(\"TypeError\")\n","expected_output":"TypeError","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const data = [2, \"10\", 1, \"3\"];\nconsole.log(data.slice().sort());\n","expected_output":"[ 1, '10', 2, '3' ]","verified_with":"Node.js v22.22.2"}],"fix":"Normalize types before sorting a mixed collection — decide whether everything should be a number or everything a string, and convert explicitly, rather than relying on either language's comparison machinery to make that decision for you (one will refuse to run at all; the other will run and hand you an answer that looks plausible while meaning nothing).","related":["sorting-mixed-none-null","js-array-sort-stringifies-numbers"],"sources":[{"title":"Python docs: Comparisons","url":"https://docs.python.org/3/reference/expressions.html#comparisons"},{"title":"Python docs: object.__lt__ and reflected comparison methods","url":"https://docs.python.org/3/reference/datamodel.html#object.__lt__"},{"title":"ECMAScript spec: Array.prototype.sort","url":"https://tc39.es/ecma262/#sec-array.prototype.sort"}],"tags":["sorting","comparison","database","json","parsing"],"test_cases":[{"inputs":[[2,"10",1,"3"]],"relation":"no_crash","note":"A sort over mixed-type input should normalize types first (for example convert everything to int, giving 1, 2, 3, 10 in numeric order) rather than raise TypeError (Python's sorted) or silently return string order (JavaScript's default sort)."}]},{"id":"reverse-sort-vs-reverse-after-sort-ties","title":"Sorting descending and \"sort ascending then reverse the list\" give different answers when there are ties","category":"sorting","assumption":"sorted(data, reverse=True) is exactly the same as list(reversed(sorted(data))) — descending sort and \"sort then flip the list\" are interchangeable.","input":{"display":"[(\"a\",1), (\"b\",1), (\"c\",2)] sorted descending by the second element, two different ways","description":"Showing the highest-scoring rows first, where some rows tie on score and the tiebreaker is \"whoever was already first stays first\" (e.g. insertion order, or an earlier sort by name)."},"what_goes_wrong":"sorted(data, key=..., reverse=True) returns [(\"c\",2), (\"a\",1), (\"b\",1)] — among the tied score-1 rows, \"a\" still comes before \"b\", matching their original order. list(reversed(sorted(data, key=...))) returns [(\"c\",2), (\"b\",1), (\"a\",1)] — the SAME tied rows now come out with \"b\" before \"a\", the opposite of their original order. The non-tied row (\"c\",2) ends up in the same place either way; only the ties disagree.","why":"A stable sort keeps elements that compare equal in their original relative order. Python documents reverse=True as sorting \"as if each comparison were reversed\" while staying stable, so tied rows keep their input order, and a JavaScript comparator that negates the ascending comparison does the same. Reversing an already-sorted list flips every position, including the order inside each group of ties. The two only agree when there are no ties.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"data = [(\"a\", 1), (\"b\", 1), (\"c\", 2)]\nby_reverse_flag = sorted(data, key=lambda t: t[1], reverse=True)\nby_sort_then_reverse = list(reversed(sorted(data, key=lambda t: t[1])))\nprint(by_reverse_flag)\nprint(by_sort_then_reverse)\nprint(by_reverse_flag == by_sort_then_reverse)\n","expected_output":"[('c', 2), ('a', 1), ('b', 1)]\n[('c', 2), ('b', 1), ('a', 1)]\nFalse","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const data = [[\"a\", 1], [\"b\", 1], [\"c\", 2]];\nconst byReverseComparator = data.slice().sort((x, y) => y[1] - x[1]);\nconst bySortThenReverse = data.slice().sort((x, y) => x[1] - y[1]).reverse();\nconsole.log(JSON.stringify(byReverseComparator));\nconsole.log(JSON.stringify(bySortThenReverse));\nconsole.log(JSON.stringify(byReverseComparator) === JSON.stringify(bySortThenReverse));\n","expected_output":"[[\"c\",2],[\"a\",1],[\"b\",1]]\n[[\"c\",2],[\"b\",1],[\"a\",1]]\nfalse","verified_with":"Node.js v22.22.2"}],"fix":"If tie order matters for a descending sort, use the language's actual descending sort (reverse=True in Python, or a comparator that negates the ascending comparison in JS) rather than sorting ascending and reversing the whole list afterward — they are not equivalent whenever ties exist.","related":[],"sources":[{"title":"Python docs: Sorting HOW TO (stability, reverse)","url":"https://docs.python.org/3/howto/sorting.html#sort-stability-and-complex-sorts"},{"title":"ECMA-262: Array.prototype.sort (stability requirement)","url":"https://tc39.es/ecma262/#sec-array.prototype.sort"}],"tags":["sorting","comparison","pagination","display"],"test_cases":[]},{"id":"sorting-mixed-none-null","title":"Python refuses to sort a list with a missing value; JavaScript sorts it two different ways depending on how you ask","category":"sorting","assumption":"A missing value (None/null/undefined) in a list of numbers sorts to a predictable, consistent place, or fails loudly the same way everywhere.","input":{"display":"[3, None, 1] in Python; [3, null, 1, undefined, 2] in JavaScript","description":"A numeric column from a database or API response where some rows legitimately have no value (an unset score, an optional field), sorted for a leaderboard or table."},"what_goes_wrong":"Python's sorted([3, None, 1]) raises TypeError. JavaScript's default sort compares elements as strings, so null sorts where the string \"null\" would (after the digits here), while undefined always goes to the very end. With a numeric comparator (a, b) => a - b, null behaves as 0 (Number(null) === 0) and moves to the front, and undefined is still last: the comparator is never called with undefined, so no comparator logic can move it.","why":"Python's < raises TypeError when neither operand's comparison method supports the other type, and None has no ordering against int. In JavaScript, the default sort uses ToString (String(null) is \"null\"), while a subtraction comparator relies on numeric conversion, where Number(null) is 0. undefined never reaches the comparator: the spec's CompareArrayElements returns \"undefined goes after\" before calling it, so undefined values always end up after every other value (and array holes after those).","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"data = [3, None, 1]\ntry:\n sorted(data)\n print(\"sorted without error\")\nexcept TypeError:\n print(\"TypeError\")\n","expected_output":"TypeError","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const data = [3, null, 1, undefined, 2];\nconsole.log(data.slice().sort());\nconsole.log(data.slice().sort((a, b) => a - b));\n","expected_output":"[ 1, 2, 3, null, undefined ]\n[ null, 1, 2, 3, undefined ]","verified_with":"Node.js v22.22.2"}],"fix":"Decide explicitly where missing values belong (first, last, or filtered out) and partition or key for that before sorting, rather than letting None/null/undefined flow into the comparison — in JavaScript especially, don't assume your comparator controls where undefined ends up; it doesn't.","related":["python-cannot-compare-str-int","nan-in-sort"],"sources":[{"title":"ECMAScript spec: CompareArrayElements","url":"https://tc39.es/ecma262/#sec-comparearrayelements"},{"title":"ECMAScript spec: Array.prototype.sort","url":"https://tc39.es/ecma262/#sec-array.prototype.sort"},{"title":"Python docs: Comparisons","url":"https://docs.python.org/3/reference/expressions.html#comparisons"}],"tags":["sorting","comparison","database","pagination","display"],"test_cases":[]},{"id":"typed-array-sort-is-numeric-by-default","title":"Int32Array.prototype.sort() sorts numerically by default; Array.prototype.sort() on the same numbers doesn't","category":"sorting","assumption":"sort() means the same thing on every array-like object in JavaScript — if plain arrays sort numbers as strings by default, typed arrays must too.","input":{"display":"[10, 9, 1, 2].sort() vs Int32Array.from([10, 9, 1, 2]).sort()","description":"Converting a plain array of numbers to a typed array (common when passing data to WebGL, audio buffers, or any binary-protocol code) and calling the same-named .sort() method on it with no comparator."},"what_goes_wrong":"The plain array's default sort() returns [1, 10, 2, 9] (string order, the usual trap). The typed array's default sort() on the identical numbers returns [1, 2, 9, 10] — correctly ascending numeric order — with no comparator passed at all. The exact same method name, called the same way, defaults to opposite comparison strategies depending only on which kind of array it's called on.","why":"The ECMAScript spec gives %TypedArray%.prototype.sort its own default comparison, defined numerically: smaller values first, -0 before +0, and NaN (possible in Float32Array/Float64Array) at the end. Typed arrays can only hold numbers (or BigInts), so there is no need for the string fallback. Array.prototype.sort's default has to work for arrays holding any mix of values, and the spec defines it with ToString instead. Python's sorted() on a list of ints always compares the integer values, so there is no equivalent split.","affects":["javascript"],"repros":[{"language":"python","status":"verified","code":"data = [10, 9, 1, 2]\nprint(sorted(data))\n","expected_output":"[1, 2, 9, 10]","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const arr = [10, 9, 1, 2];\nconsole.log(arr.slice().sort());\nconst typed = Int32Array.from(arr);\ntyped.sort();\nconsole.log(Array.from(typed));\n","expected_output":"[ 1, 10, 2, 9 ]\n[ 1, 2, 9, 10 ]","verified_with":"Node.js v22.22.2"}],"fix":"Don't assume .sort()'s default behavior transfers between plain arrays and typed arrays, or rely on that difference — pass an explicit numeric comparator to Array.prototype.sort() regardless, so the code reads the same way (and behaves the same way) whichever kind of array it ends up operating on.","related":["js-array-sort-stringifies-numbers"],"sources":[{"title":"ECMA-262: %TypedArray%.prototype.sort","url":"https://tc39.es/ecma262/#sec-%typedarray%.prototype.sort"},{"title":"MDN: TypedArray.prototype.sort()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/sort"}],"tags":["sorting","comparison","display"],"test_cases":[]},{"id":"arabic-indic-digit-parses","title":"Python's int(\"\\u0663\") is 3; JavaScript's Number(\"\\u0663\") is NaN","category":"unicode","assumption":"A \"digits only\" check (\\d or isdigit) and a numeric parser agree on what counts as a digit, and that agreement is the same in every language.","input":{"display":"\"\\u0663\" (ARABIC-INDIC DIGIT THREE, from Arabic-script keyboards/locales)","description":"A quantity, price, or phone-number field filled in using a non-Latin numeral system that is still, by Unicode's own definition, a decimal digit."},"what_goes_wrong":"In Python, \"\\u0663\".isdigit() is True, \"\\u0663\".isdecimal() is True, int(\"\\u0663\") == 3, and a bare regex r\"\\d\" matches it. In JavaScript, the ASCII-only /\\d/ regex does NOT match it, and both Number(\"\\u0663\") and parseInt(\"\\u0663\", 10) return NaN — the identical character is \"a valid number\" in one language's standard library and \"not a number at all\" in the other's.","why":"ARABIC-INDIC DIGIT THREE has General_Category Nd and Numeric_Type=Decimal, exactly like ASCII \"3\": it is a real decimal digit in its own numeral system. Python's int() accepts strings made of any Unicode decimal digits (category Nd), so int(\"\\u0663\") is 3 and int(\"\\u0663\\u0661\") is 31, and the re module's \\d matches any Nd character for str patterns unless re.ASCII is set. In JavaScript, \\d is defined as exactly [0-9], with or without the u or v flag, and Number()/parseInt() only accept ASCII digits in their string grammar, so the same character is rejected.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import re\ns = \"\\u0663\"\nprint(s.isdigit(), s.isdecimal())\nprint(int(s), int(s) == 3)\nprint(re.match(r\"^\\d+$\", s) is not None)\n","expected_output":"True True\n3 True\nTrue","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const s = \"\\u0663\";\nconsole.log(/^\\d+$/.test(s));\nconsole.log(Number(s), parseInt(s, 10));\n","expected_output":"false\nNaN NaN","verified_with":"Node.js v22.22.2"}],"fix":"If you intend \"ASCII digits only\", say so explicitly (Python: re.match(r\"\\d+\", s, re.ASCII); JavaScript's \\d already is ASCII-only). If you intend to accept any script's digits, normalize non-ASCII decimal digits to ASCII before further processing (Python's unicodedata.decimal() per character, or NFKC for digits that have compatibility decompositions) rather than relying on int()/Number() to agree across languages — they don't.","related":["nfkc-compatibility-folding","js-word-regex-ascii-only"],"sources":[{"title":"UAX #44: Numeric_Type","url":"https://www.unicode.org/reports/tr44/#Numeric_Type"},{"title":"ECMAScript spec: ToNumber applied to the String type","url":"https://tc39.es/ecma262/#sec-tonumber-applied-to-the-string-type"},{"title":"ECMAScript spec: CharacterClassEscape (\\d)","url":"https://tc39.es/ecma262/#sec-characterclassescape"},{"title":"Python docs: int()","url":"https://docs.python.org/3/library/functions.html#int"},{"title":"Python docs: re (\\d)","url":"https://docs.python.org/3/library/re.html#regular-expression-syntax"}],"tags":["validation","forms","parsing","i18n","money"],"test_cases":[{"inputs":["٣"],"relation":"no_crash","note":"A \"digits only\" validator should either accept this consistently in both Python and JS or reject it consistently in both — not one and not the other, by accident of which language's \\d it used."}]},{"id":"bidi-override-filename-spoof","title":"A filename that ends in \".exe\" can display as if it ends in \".png\"","category":"unicode","assumption":"What a filename's extension check says and what a user sees when they look at the filename are the same thing.","input":{"display":"\"invoice\\u202egnp.exe\" (RIGHT-TO-LEFT OVERRIDE, U+202E, inserted before the reversed tail \"gnp.exe\")","description":"An email attachment or download named by an attacker to look safe: a real file ending in a blocked extension, disguised by an embedded bidi control character."},"what_goes_wrong":"name.endswith(\".exe\") is True — the raw string genuinely ends in \".exe\" and a naive extension blocklist correctly flags it — but when this exact string is rendered by an OS file browser, email client, or terminal that honors bidi control characters, everything after U+202E is displayed right-to-left, so \"gnp.exe\" is painted in reverse as \"exe.png\": a human looking at the file sees what appears to be a harmless PNG, not the executable the code just correctly identified.","why":"U+202E (RIGHT-TO-LEFT OVERRIDE) is a Unicode bidirectional formatting control: it tells a bidi-aware renderer to lay out everything that follows as if it were right-to-left text, purely for display, with no effect on the underlying code point sequence or on any string method. This is the classic \"Unicode RLO\" spoof used to disguise malware extensions in the wild: the code's view of the string (and any endswith/regex check on it) is accurate and unaffected, which is precisely why the attack works on humans rather than on code — the mismatch is between what is stored and what a bidi-rendering surface draws, not a parsing bug.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"name = \"invoice\\u202egnp.exe\"\nprint(len(name), name.endswith(\".exe\"))\nprint(name.encode(\"unicode_escape\"))\n","expected_output":"15 True\nb'invoice\\\\u202egnp.exe'","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const name = \"invoice\\u202egnp.exe\";\nconsole.log(name.length, name.endsWith(\".exe\"));\nconsole.log(JSON.stringify(name));\n","expected_output":"15 true\n\"invoicegnp.exe\"","verified_with":"Node.js v22.22.2"}],"fix":"Strip or reject Unicode bidirectional format characters (U+202A-U+202E, U+2066-U+2069) from filenames and other security-relevant display strings at the point of upload/creation, and never trust a rendered filename's apparent extension — always resolve the extension from the raw code point sequence.","related":["zalgo-combining-mark-stacking"],"sources":[{"title":"Unicode Standard Annex #9: Unicode Bidirectional Algorithm","url":"https://www.unicode.org/reports/tr9/"},{"title":"Big List of Naughty Strings (MIT)","url":"https://github.com/minimaxir/big-list-of-naughty-strings"}],"tags":["filename","upload","security","display","validation"],"test_cases":[{"inputs":["invoicegnp.exe",".exe"],"relation":"returns","expected":true,"note":"their_function(name, ext) checking name.endsWith(ext) correctly returns true on the raw string — the vulnerability is that a human reading the rendered name will disagree with this correct answer."}]},{"id":"cyrillic-homoglyph-paypal-spoof","title":"\"paypal\" with one Cyrillic letter looks identical and NFKC does not catch it","category":"unicode","assumption":"Two strings that render identically are either literally equal, or become equal after Unicode normalization.","input":{"display":"\"payp\\u0430l\" (Cyrillic \\u0430, CYRILLIC SMALL LETTER A, in place of the fourth letter) vs \"paypal\"","description":"An impersonation username, support-ticket display name, or phishing domain label built by swapping one Latin letter for a visually identical letter from another script."},"what_goes_wrong":"The spoofed string has the same length as the real word and looks the same in most fonts, but == and a database UNIQUE constraint treat it as a different value, and NFKC normalization leaves it unchanged, so it does not merge with the real spelling.","why":"Unicode gives Cyrillic \\u0430 and Latin \"a\" separate code points because they are different letters in different scripts, even though most fonts draw them the same; UTS #39's confusables data lists exactly this kind of pair. Normalization (NFC, NFKC) only replaces a character with an equivalent that Unicode's decomposition data declares, such as a ligature, a fullwidth form or a superscript; there is no such mapping from Cyrillic \\u0430 to Latin a, so NFKC leaves the lookalike in place. UTS #39 provides the checks that catch it: comparing \"skeletons\" built from confusables.txt (which maps \\u0430 to a), or mixed-script detection, which flags a single word that mixes Latin and Cyrillic letters.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import unicodedata\na = \"paypal\"\nb = \"payp\\u0430l\"\nprint(a == b, len(a), len(b))\nprint(unicodedata.normalize(\"NFKC\", a) == unicodedata.normalize(\"NFKC\", b))\nprint([hex(ord(c)) for c in b])\n","expected_output":"False 6 6\nFalse\n['0x70', '0x61', '0x79', '0x70', '0x430', '0x6c']","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const a = \"paypal\";\nconst b = \"payp\\u0430l\";\nconsole.log(a === b, a.length, b.length);\nconsole.log(a.normalize(\"NFKC\") === b.normalize(\"NFKC\"));\n","expected_output":"false 6 6\nfalse","verified_with":"Node.js v22.22.2"}],"fix":"For security-sensitive comparisons (brand names, support impersonation, domain labels), run Unicode's confusables skeleton algorithm (UTS #39) in addition to normalization, or restrict the field to a single script/ASCII where the context allows it, rather than trusting that normalization catches lookalikes.","related":["zero-width-space-username","fullwidth-bypasses-keyword-filter"],"sources":[{"title":"UTS #39: Unicode Security Mechanisms, Confusable Detection","url":"https://www.unicode.org/reports/tr39/#Confusable_Detection"},{"title":"UTS #39: Mixed-Script Detection","url":"https://www.unicode.org/reports/tr39/#Mixed_Script_Detection"},{"title":"confusables.txt","url":"https://www.unicode.org/Public/security/latest/confusables.txt"}],"tags":["username","uniqueness","security","login","validation","i18n"],"test_cases":[{"inputs":["paypal","paypаl"],"relation":"equal","note":"A confusable-aware key for impersonation checks (for example the UTS #39 skeleton) should map both strings to the same key. Plain equality and NFKC both say they differ, which is the gap."}]},{"id":"fullwidth-bypasses-keyword-filter","title":"A fullwidth \"select\" slips past a keyword filter, then reappears as literal \"select\" after normalization","category":"unicode","assumption":"If a blocklist check on the raw input says a dangerous keyword isn't present, it isn't present anywhere downstream either.","input":{"display":"\"\\uff53\\uff45\\uff4c\\uff45\\uff43\\uff54\" (FULLWIDTH LATIN letters spelling \"select\")","description":"User input containing a blocked keyword spelled with fullwidth Unicode letters (common in East Asian input methods, and a known WAF/filter-bypass technique), which is later normalized for display or by a downstream system/collation before being used."},"what_goes_wrong":"\"select\" in payload.lower() is False on the raw fullwidth string, so a naive keyword blocklist lets it through. Once the same string is NFKC-normalized (for display, by a second validation layer, or by a downstream service), it becomes the literal ASCII word \"select\", and the keyword the filter was supposed to catch is present after all. Some database collations also compare fullwidth and ASCII letters as equal without changing the stored text.","why":"FULLWIDTH LATIN SMALL LETTER <X> code points (U+FF41-U+FF5A) are compatibility characters: Unicode's own decomposition mapping, used by NFKC, defines each one as equivalent to its ordinary ASCII letter, specifically so systems that care about the abstract text (rather than its East-Asian-typesetting width) can fold them together. A substring/equality check on the un-normalized string sees entirely different code points and finds no match; the moment anything in the pipeline normalizes before the keyword check runs, the two representations become the same string. This is why normalize-then-validate is safe and validate-then-normalize is not — order matters.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import unicodedata\npayload = \"\\uff53\\uff45\\uff4c\\uff45\\uff43\\uff54\"\nprint(\"select\" in payload.lower())\nnormalized = unicodedata.normalize(\"NFKC\", payload)\nprint(normalized, \"select\" in normalized.lower())\n","expected_output":"False\nselect True","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const payload = \"\\uff53\\uff45\\uff4c\\uff45\\uff43\\uff54\";\nconsole.log(payload.toLowerCase().includes(\"select\"));\nconst normalized = payload.normalize(\"NFKC\");\nconsole.log(normalized, normalized.toLowerCase().includes(\"select\"));\n","expected_output":"false\nselect true","verified_with":"Node.js v22.22.2"}],"fix":"Normalize (NFKC) untrusted text BEFORE running any keyword, blocklist, or validation check against it, never after. If anything later in the pipeline normalizes or compares width-insensitively (a display layer, another service, a database collation), the filter in front of it must assume the same folding. Keyword blocklists are a weak defence against injection anyway; parameterized queries are the real fix for SQL.","related":["nfkc-compatibility-folding","cyrillic-homoglyph-paypal-spoof"],"sources":[{"title":"Unicode Standard Annex #15: Unicode Normalization Forms (NFKC)","url":"https://www.unicode.org/reports/tr15/"},{"title":"Unicode Technical Standard #39: Unicode Security Mechanisms","url":"https://www.unicode.org/reports/tr39/"}],"tags":["security","validation","normalization","parsing","forms"],"test_cases":[{"inputs":["select"],"relation":"no_crash","note":"A keyword blocklist must reject this string if it would reject \"select\" after any normalization step anywhere later in the system — check both forms, or normalize first."}]},{"id":"german-eszett-case-fold","title":"\"STRASSE\".lower() is not \"straße\" — uppercasing and lowercasing aren't inverses","category":"unicode","assumption":"If a.upper() == b, then a == b.lower().","input":{"display":"\"stra\\u00dfe\" (German for \"street\", with ß U+00DF) vs \"STRASSE\"","description":"A German street name or surname containing ß, compared against an all-caps form from a passport, an old database export, or a form that forces uppercase input."},"what_goes_wrong":"\"stra\\u00dfe\".upper() correctly equals \"STRASSE\", but \"STRASSE\".lower() comes back as \"strasse\", not \"stra\\u00dfe\" — so the round trip breaks and a plain .lower()-keyed uniqueness check treats the two spellings as different strings.","why":"Unicode's default full uppercase mapping of ß is the two letters \"SS\" (a capital ẞ, U+1E9E, exists but is not the default mapping), which is why \"stra\\u00dfe\".upper() == \"STRASSE\" and why this case operation changes string length. The mapping only goes one way: lowercasing \"SS\" gives \"ss\", because plenty of words have a real double s. Python's str.casefold() (added in Python 3.3) applies Unicode case folding, which maps ß to \"ss\", so casefold() == casefold() succeeds where lower() == lower() fails. JavaScript has no casefold(); toLowerCase() and toUpperCase() show the same asymmetry, and the built-in caseless comparison is Intl.Collator or localeCompare with sensitivity \"base\", which treats \"straße\" and \"STRASSE\" as equal.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"a = \"STRASSE\"\nb = \"stra\\u00dfe\"\nprint(b.upper() == a, a.lower() == b)\nprint(a.casefold() == b.casefold())\nprint(\"\\u00df\".upper(), len(\"\\u00df\".upper()))\n","expected_output":"True False\nTrue\nSS 2","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const a = \"STRASSE\";\nconst b = \"stra\\u00dfe\";\nconsole.log(b.toUpperCase() === a, a.toLowerCase() === b);\nconsole.log(\"\\u00df\".toUpperCase(), \"\\u00df\".toUpperCase().length);\n","expected_output":"true false\nSS 2","verified_with":"Node.js v22.22.2"}],"fix":"For caseless comparison, use Python's str.casefold() rather than str.lower(). In JavaScript, compare with new Intl.Collator(undefined, { sensitivity: \"base\" }).compare(a, b) === 0 (note that \"base\" also ignores accents), or normalize ß to \"ss\" explicitly before comparing. For stored uniqueness keys, also normalize (NFKC or NFC) before folding.","related":["turkish-dotless-i-case-mapping","greek-final-sigma-casefold"],"sources":[{"title":"Unicode Standard: SpecialCasing.txt (ß → SS uppercase mapping)","url":"https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt"},{"title":"Python docs: str.casefold","url":"https://docs.python.org/3/library/stdtypes.html#str.casefold"},{"title":"Unicode Technical Standard #10 / TR21: Case Mappings","url":"https://www.unicode.org/notes/tn21/"},{"title":"MDN: Intl.Collator() constructor, sensitivity option","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator"}],"tags":["username","uniqueness","login","addresses","i18n","validation","comparison"],"test_cases":[{"inputs":["STRASSE","straße"],"relation":"equal","note":"A caseless-match key should map both to the same value (Python's casefold() does; lower() does not)."}]},{"id":"grapheme-vs-codepoint-length","title":"One family emoji, three different \"lengths\": 1, 5, and 8","category":"unicode","assumption":"A string's length property tells you how many characters a person sees on screen.","input":{"display":"\"\\u{1F468}\\u200D\\u{1F469}\\u200D\\u{1F467}\" (man + ZWJ + woman + ZWJ + girl = one family emoji)","description":"Any emoji picker's \"family\"/\"couple with heart\"/skin-tone-modified emoji, typed into a bio, nickname, or chat message with a character-count limit."},"what_goes_wrong":"The single glyph a user sees as ONE character reports length 8 in JavaScript's .length, 5 via JavaScript's [...s].length or Python's len(), and 1 via JavaScript's Intl.Segmenter grapheme count. A \"max 10 characters\" bio limit enforced with .length rejects two family emoji (16 code units) that the user sees as two characters, and truncating by any of the non-grapheme measures can cut the glyph apart (see the truncation exhibits).","why":"JavaScript strings are sequences of UTF-16 code units, and .length counts those units; each of the three emoji here is outside the Basic Multilingual Plane, so it needs a surrogate pair (2 code units), giving 3×2 + 2 ZWJ = 8. Python strings are sequences of Unicode code points, and len() counts those; the same string is 3 emoji + 2 ZWJ = 5 code points (the spread operator [...s] in JavaScript iterates by code point too, also giving 5). But a person perceives the whole ZWJ-joined sequence as a single \"grapheme cluster\" — exactly one visual unit — which is what JavaScript's Intl.Segmenter({granularity: \"grapheme\"}) counts, and what Python's standard library has no built-in equivalent for.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"s = \"\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F467\"\nprint(len(s))\n","expected_output":"5","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const s = \"\\u{1F468}\\u200D\\u{1F469}\\u200D\\u{1F467}\";\nconsole.log(s.length);\nconsole.log([...s].length);\nconst seg = new Intl.Segmenter(\"en\", { granularity: \"grapheme\" });\nconsole.log([...seg.segment(s)].length);\n","expected_output":"8\n5\n1","verified_with":"Node.js v22.22.2"}],"fix":"Enforce user-facing length limits with a grapheme-cluster counter, not raw .length/len(). In JavaScript use Intl.Segmenter (built in, no dependency); Python's standard library has no grapheme-cluster segmenter, so either accept code-point length as an approximation (it undercounts far less often than UTF-16 length) or add a UAX #29-compliant library.","related":["truncation-splits-surrogate-pair","zalgo-combining-mark-stacking"],"sources":[{"title":"Unicode Standard Annex #29: Unicode Text Segmentation (grapheme clusters)","url":"https://www.unicode.org/reports/tr29/"},{"title":"MDN: Intl.Segmenter","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter"},{"title":"Unicode Emoji ZWJ Sequences","url":"https://www.unicode.org/Public/emoji/latest/emoji-zwj-sequences.txt"}],"tags":["length validation","forms","display","truncation","i18n","validation"],"test_cases":[]},{"id":"greek-final-sigma-casefold","title":"lower() and casefold() disagree on the same Greek word","category":"unicode","assumption":"casefold() is just a more aggressive lower(), so it can only make more strings equal, never fewer, and never disagree with lower() about how to spell an individual word.","input":{"display":"\"\\u039f\\u0394\\u03a5\\u03a3\\u03a3\\u0395\\u03a5\\u03a3\" (Greek \"ODYSSEUS\", all-capital sigma \\u03a3)","description":"Comparing a Greek name typed in all caps against its lowercase spelling, or canonicalizing Greek text for a search index."},"what_goes_wrong":"word.lower() produces \"\\u03bf\\u03b4\\u03c5\\u03c3\\u03c3\\u03b5\\u03c5\\u03c2\" (ending in final sigma \\u03c2, the form Greek orthography requires at the end of a word), but word.casefold() produces \"\\u03bf\\u03b4\\u03c5\\u03c3\\u03c3\\u03b5\\u03c5\\u03c3\" (ending in plain sigma \\u03c3) — two different strings from the exact same input, so lower() == casefold() is False for this word.","why":"Greek has two lowercase forms of sigma: \\u03c3 inside a word and \\u03c2 at the end of a word. Python's str.lower() applies the Final_Sigma rule from SpecialCasing.txt, a context condition (the sigma follows a cased letter and is not followed by one) that picks \\u03c2 for the last letter here. str.casefold() exists for caseless MATCHING and uses CaseFolding.txt, which maps \\u03a3, \\u03c3 and \\u03c2 all to \\u03c3 with no context, so that words differing only in sigma form compare equal. JavaScript has no casefold(), so it has no built-in way to show this disagreement; its toLowerCase() applies the same Final_Sigma rule and matches Python's lower().","affects":["python"],"repros":[{"language":"python","status":"verified","code":"word = \"\\u039f\\u0394\\u03a5\\u03a3\\u03a3\\u0395\\u03a5\\u03a3\"\nprint(word.lower())\nprint(word.casefold())\nprint(word.lower() == word.casefold())\n","expected_output":"οδυσσευς\nοδυσσευσ\nFalse","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const word = \"\\u039f\\u0394\\u03a5\\u03a3\\u03a3\\u0395\\u03a5\\u03a3\";\nconsole.log(word.toLowerCase());\n","expected_output":"οδυσσευς","verified_with":"Node.js v22.22.2"}],"fix":"Do not assume casefold() is a strict superset of lower()'s equalities, or that it preserves orthographic rules — it is only guaranteed to make caseless comparison consistent, not to produce \"correctly spelled\" lowercase text. For display, use lower(); for comparison, use casefold(); never mix the two results together or assert they're interchangeable.","related":["german-eszett-case-fold"],"sources":[{"title":"Unicode Standard: SpecialCasing.txt (Greek final sigma rule)","url":"https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt"},{"title":"Unicode Standard: CaseFolding.txt","url":"https://www.unicode.org/Public/UCD/latest/ucd/CaseFolding.txt"},{"title":"Python docs: str.casefold","url":"https://docs.python.org/3/library/stdtypes.html#str.casefold"}],"tags":["comparison","sorting","i18n","normalization","search"],"test_cases":[]},{"id":"js-word-regex-ascii-only","title":"The same regex \\w+ accepts \"café\" in Python and rejects it in JavaScript","category":"unicode","assumption":"\\w in a regex means \"letters\", in whichever language you're running it, maybe with an extra flag for Unicode.","input":{"display":"/^\\w+$/ applied to \"caf\\u00e9\" and to \"\\u65e5\\u672c\\u8a9e\" (Japanese for \"Japanese language\")","description":"A \"letters and numbers only\" validator applied to a name field, reused as-is between a Python backend and a JavaScript frontend."},"what_goes_wrong":"Python's re.fullmatch(r\"\\w+\", \"caf\\u00e9\") matches, and even matches \"\\u65e5\\u672c\\u8a9e\". The textually identical /^\\w+$/ in JavaScript rejects both — and adding the /u (Unicode) flag, which people reach for specifically to fix Unicode regex problems, does not change the result at all.","why":"In JavaScript, \\w is defined by the ECMAScript spec as the ASCII class [A-Za-z0-9_]. The u flag does not turn it into a Unicode letter class; the only change is that with both u and i set it also matches U+017F (long s) and U+212A (Kelvin sign), because they case-fold to s and k. To match non-ASCII letters in JavaScript you need a Unicode property escape such as \\p{L}, which requires the u (or v) flag. Python's re module makes \\w Unicode-aware by default for str patterns (letters, digits and underscore in the Unicode sense) unless you pass re.ASCII. Same regex syntax, opposite default behaviour.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import re\nprint(bool(re.fullmatch(r\"\\w+\", \"caf\\u00e9\")))\nprint(bool(re.fullmatch(r\"\\w+\", \"\\u65e5\\u672c\\u8a9e\")))\nprint(bool(re.fullmatch(r\"\\w+\", \"caf\\u00e9\", re.ASCII)))\n","expected_output":"True\nTrue\nFalse","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const name = \"caf\\u00e9\";\nconsole.log(/^\\w+$/.test(name), /^\\w+$/u.test(name));\nconsole.log(/^[\\p{L}]+$/u.test(name));\nconst name2 = \"\\u65e5\\u672c\\u8a9e\";\nconsole.log(/^\\w+$/.test(name2), /^[\\p{L}]+$/u.test(name2));\n","expected_output":"false false\ntrue\nfalse true","verified_with":"Node.js v22.22.2"}],"fix":"Never port a \\w-based regex between Python and JavaScript expecting matching behavior. In JavaScript, use /^[\\p{L}\\p{N}_]+$/u for a Unicode-aware \"word characters\" check; in Python, pass re.ASCII explicitly if you actually want \\w restricted to [A-Za-z0-9_].","related":["arabic-indic-digit-parses"],"sources":[{"title":"ECMA-262: RegExp \\w class definition (CharacterClassEscape)","url":"https://tc39.es/ecma262/#sec-characterclassescape"},{"title":"MDN: Unicode property escapes","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape"},{"title":"Python docs: re module, \\w and re.ASCII","url":"https://docs.python.org/3/library/re.html#re.ASCII"}],"tags":["validation","forms","parsing","names","i18n"],"test_cases":[{"inputs":["café"],"relation":"no_crash","note":"A \"letters and numbers only\" validator ported between a Python backend and a JS frontend must not accept this on one side and reject it on the other."}]},{"id":"nfc-nfd-same-name-two-accounts","title":"Two usernames that look identical, compare unequal, and both register","category":"unicode","assumption":"If two strings render the same, they are the same string.","input":{"display":"\"Jos\\u00e9\" (precomposed é, 4 code points) vs \"Jose\\u0301\" (e + combining acute, 5 code points)","description":"The same visible name typed on two different systems: macOS file APIs and some keyboards produce the decomposed form, most web forms produce the precomposed form."},"what_goes_wrong":"A uniqueness check with == or a plain UNIQUE index lets both through, so two accounts share one visible name.","why":"Unicode allows the same abstract character to be encoded either as one precomposed code point (U+00E9) or as a base letter plus a combining mark (U+0065 U+0301). The two sequences are canonically equivalent, so every renderer draws them identically, but their code points differ and every byte- or code-point comparison says they are different. Normalizing both sides to the same form (NFC or NFD) before comparing or storing makes them equal.","affects":["python","javascript","postgres","mysql","any byte-wise comparison"],"repros":[{"language":"python","status":"verified","code":"import unicodedata\na = \"Jos\\u00e9\"\nb = \"Jose\\u0301\"\nprint(a == b, len(a), len(b))\nprint(unicodedata.normalize(\"NFC\", a) == unicodedata.normalize(\"NFC\", b))\n","expected_output":"False 4 5\nTrue","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const a = \"Jos\\u00e9\";\nconst b = \"Jose\\u0301\";\nconsole.log(a === b, a.length, b.length);\nconsole.log(a.normalize(\"NFC\") === b.normalize(\"NFC\"));\n","expected_output":"false 4 5\ntrue","verified_with":"Node.js v22.22.2"}],"fix":"Normalize to NFC (or NFKC for identifiers, which also folds compatibility forms) at the boundary where the value enters your system, and store the normalized form; compare normalized forms only.","related":[],"sources":[{"title":"Unicode Standard Annex #15: Unicode Normalization Forms","url":"https://www.unicode.org/reports/tr15/"},{"title":"Python docs: unicodedata.normalize","url":"https://docs.python.org/3/library/unicodedata.html#unicodedata.normalize"},{"title":"MDN: String.prototype.normalize()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize"}],"tags":["username","uniqueness","login","normalization","nfc","nfd","unicode","string comparison","database unique index"],"test_cases":[{"inputs":["José","José"],"relation":"equal","note":"Your username canonicalizer should map both spellings to the same key."}]},{"id":"nfkc-compatibility-folding","title":"A superscript \"\\u00b2\" says isdigit() but int() refuses it","category":"unicode","assumption":"If isdigit() returns True (or a /\\d/ regex matches), the value is safe to pass to a numeric parser.","input":{"display":"\"\\u00b2\" (SUPERSCRIPT TWO, U+00B2), fullwidth \"\\uff46\\uff4f\\uff4f\" (\"foo\"), ligature \"\\ufb01le\" (\"file\")","description":"Fullwidth letters pasted from CJK input methods, a superscript exponent copied from a web page into a quantity field, or a ligature character from a PDF copy-paste landing in a filename or search box."},"what_goes_wrong":"\"\\u00b2\".isdigit() is True, but int(\"\\u00b2\") raises ValueError; meanwhile fullwidth \"\\uff46\\uff4f\\uff4f\" does not equal \"foo\" and the 3-character ligature \"\\ufb01le\" has length 4 after normalization even though no character was literally inserted.","why":"Python's str.isdigit() is true for any character whose Numeric_Type is Digit or Decimal, which includes superscript and subscript digits, but int() only accepts Numeric_Type=Decimal (General_Category Nd) characters, so isdigit() cannot predict whether int() will succeed; str.isdecimal() is the check that matches int(). NFKC (UAX #15) replaces compatibility characters, such as fullwidth letters, ligatures and superscripts, with their ordinary equivalents, which is why NFKC turns \"\\u00b2\" into \"2\" and makes the fullwidth string equal \"foo\". It does not fix every case: ETHIOPIC DIGIT ONE (U+1369) is isdigit() but not isdecimal(), has no compatibility decomposition, and still fails int() after NFKC. A single compatibility character can also expand: \"\\ufb01\" (the fi ligature) becomes two letters, so the 3-code-point \"\\ufb01le\" normalizes to 4.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import unicodedata\nsup = \"\\u00b2\"\nprint(sup.isdigit(), sup.isdecimal())\ntry:\n int(sup)\nexcept ValueError as e:\n print(\"ValueError\")\nn = unicodedata.normalize(\"NFKC\", sup)\nprint(n, n.isdecimal(), int(n))\n\nfullwidth = \"\\uff46\\uff4f\\uff4f\"\nprint(fullwidth == \"foo\", unicodedata.normalize(\"NFKC\", fullwidth) == \"foo\")\n\nlig = \"\\ufb01le\"\nprint(len(lig), len(unicodedata.normalize(\"NFKC\", lig)))\n","expected_output":"True False\nValueError\n2 True 2\nFalse True\n3 4","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const sup = \"\\u00b2\";\nconsole.log(/^\\d+$/.test(sup), Number(sup));\nconst n = sup.normalize(\"NFKC\");\nconsole.log(n, /^\\d+$/.test(n), Number(n));\n\nconst fullwidth = \"\\uff46\\uff4f\\uff4f\";\nconsole.log(fullwidth === \"foo\", fullwidth.normalize(\"NFKC\") === \"foo\");\n\nconst lig = \"\\ufb01le\";\nconsole.log(lig.length, lig.normalize(\"NFKC\").length);\n","expected_output":"false NaN\n2 true 2\nfalse true\n3 4","verified_with":"Node.js v22.22.2"}],"fix":"Normalize untrusted text with NFKC at the input boundary before validating it as an identifier, filename, or number, and when checking \"is this parseable as an integer\" use str.isdecimal() in Python (not isdigit()) or attempt-and-catch the parse — never trust isdigit()/regex \\d alone to predict int()/Number() success.","related":["arabic-indic-digit-parses","fullwidth-bypasses-keyword-filter","nfc-nfd-same-name-two-accounts"],"sources":[{"title":"Unicode Standard Annex #15: Unicode Normalization Forms (NFKC)","url":"https://www.unicode.org/reports/tr15/"},{"title":"Python docs: str.isdigit / str.isdecimal","url":"https://docs.python.org/3/library/stdtypes.html#str.isdigit"}],"tags":["username","validation","normalization","parsing","length validation","forms"],"test_cases":[{"inputs":["²"],"relation":"raises","note":"A strict integer parser should reject this (Python's int() raises ValueError) even though isdigit() returns True; validate with isdecimal() or attempt-and-catch, or NFKC-normalize first if you intend to accept it as 2."}]},{"id":"truncation-orphans-combining-mark","title":"\"Take the last character of the name\" for an avatar initial can return a bare accent mark","category":"unicode","assumption":"The last character of a person's name is always a letter you can use as an initial.","input":{"display":"\"Jose\\u0301\" (decomposed \\u00e9: \"e\" + COMBINING ACUTE ACCENT, 5 code points) and name[-1] / name.slice(-1)","description":"Generating a one-letter avatar/initial from a user's display name by taking its first or last character, where the name arrived in decomposed (NFD) form from a different system or keyboard."},"what_goes_wrong":"name[-1] (Python) and name.slice(-1) (JavaScript) both return a single COMBINING ACUTE ACCENT by itself — not the letter \"e\" or the letter \"\\u00e9\" a person would expect — because in a decomposed string the accent is its own trailing code point. The result is not even classified as a letter.","why":"Unicode represents an accented letter either as one precomposed code point (U+00E9) or as a base letter followed by a separate combining mark (U+0065 U+0301). A combining mark always comes AFTER the base character it modifies, so taking the last code point (Python) or code unit (JavaScript) of a decomposed string can return the mark alone. That isolated mark has General Category Mn (\"Mark, nonspacing\"), not a letter category, which is why isalpha() and \\p{L} both reject it. Shown on its own it has no base to attach to; depending on the renderer it appears floating over nothing, over a dotted-circle placeholder, or attached to whatever text precedes it.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import unicodedata\nname = \"Jose\\u0301\"\ninitial = name[-1]\nprint(name, len(name))\nprint(repr(initial), initial.isalpha(), unicodedata.category(initial))\n","expected_output":"José 5\n'́' False Mn","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const name = \"Jose\\u0301\";\nconst initial = name.slice(-1);\nconsole.log(name, name.length);\nconsole.log(JSON.stringify(initial), /\\p{L}/u.test(initial));\n","expected_output":"José 5\n\"́\" false","verified_with":"Node.js v22.22.2"}],"fix":"Normalize to NFC before taking a single character for display (so the accent recombines into one code point with its base), or better, take the first/last grapheme cluster (Intl.Segmenter in JS) rather than the first/last code point/code unit.","related":["grapheme-vs-codepoint-length","nfc-nfd-same-name-two-accounts","truncation-splits-surrogate-pair"],"sources":[{"title":"Unicode Standard Annex #15: Unicode Normalization Forms","url":"https://www.unicode.org/reports/tr15/"},{"title":"Unicode Technical Report #44: General Category values (Mn)","url":"https://www.unicode.org/reports/tr44/#General_Category_Values"}],"tags":["names","display","truncation","normalization","i18n"],"test_cases":[{"inputs":["José"],"relation":"no_crash","note":"An avatar-initial function should not return an isolated combining mark for this (valid, real-world) decomposed name."}]},{"id":"truncation-splits-surrogate-pair","title":"Truncating a JavaScript string to \"4 characters\" can produce an unencodable string","category":"unicode","assumption":"Slicing a string to the first N characters always produces a valid, re-encodable string.","input":{"display":"\"Hi \\u{1F600}\".slice(0, 4) where \\u{1F600} is GRINNING FACE (outside the Basic Multilingual Plane)","description":"A bio/comment preview truncated to a fixed character count for a card or notification, where the full text happens to end exactly on an emoji."},"what_goes_wrong":"slice(0, 4) cuts between the two UTF-16 code units of the emoji, leaving a lone surrogate. encodeURIComponent() on the result throws \"URI malformed\", and converting it to UTF-8 (Buffer.from, TextEncoder) silently replaces it with U+FFFD, the replacement character.","why":"JavaScript strings are sequences of 16-bit UTF-16 code units, and .length and .slice() count code units. Any code point above U+FFFF (which includes most emoji) is stored as a surrogate pair, and slicing between the two halves leaves a lone surrogate, which is not well-formed UTF-16. Lone surrogates have no UTF-8 encoding, so encodeURIComponent throws, while TextEncoder and Buffer substitute U+FFFD. Python's str indexes by code point, so a character-count slice never splits a code point; but Python code that truncates encoded UTF-8 by BYTE count (common for a database column limit) has the same failure one level down: decoding raises UnicodeDecodeError, or substitutes U+FFFD with errors=\"replace\".","affects":["javascript"],"repros":[{"language":"python","status":"verified","code":"s = \"Hi \\U0001F600\"\nprint(len(s), s[:4] == s)\nb = s.encode(\"utf-8\")\nbcut = b[:5]\ntry:\n bcut.decode(\"utf-8\")\n print(\"decoded clean\")\nexcept UnicodeDecodeError as e:\n print(\"UnicodeDecodeError\")\nprint(bcut.decode(\"utf-8\", errors=\"replace\"))\n","expected_output":"4 True\nUnicodeDecodeError\nHi �","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const s = \"Hi \\u{1F600}\";\nconsole.log(s.length);\nconst cut = s.slice(0, 4);\nconsole.log(JSON.stringify(cut), cut.length);\nconsole.log(Buffer.from(cut, \"utf8\"));\ntry {\n encodeURIComponent(cut);\n console.log(\"encoded clean\");\n} catch (e) {\n console.log(e.message);\n}\n","expected_output":"5\n\"Hi \\ud83d\" 4\n<Buffer 48 69 20 ef bf bd>\nURI malformed","verified_with":"Node.js v22.22.2"}],"fix":"Truncate by grapheme cluster (Intl.Segmenter in JS) or at minimum by code point (Array.from(s) or [...s]), never by raw .length/slice() index, when the text may contain astral characters. In Python, never truncate encoded UTF-8 bytes to a fixed byte budget without first decoding, truncating the string, and re-encoding.","related":["grapheme-vs-codepoint-length","truncation-orphans-combining-mark"],"sources":[{"title":"Unicode Standard: Surrogates (Chapter 3, UTF-16)","url":"https://www.unicode.org/versions/latest/"},{"title":"MDN: String.prototype.slice()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice"},{"title":"Python docs: codecs error handlers (UnicodeDecodeError)","url":"https://docs.python.org/3/library/codecs.html#error-handlers"}],"tags":["truncation","length validation","display","parsing","url"],"test_cases":[{"inputs":["Hi 😀",4],"relation":"no_crash","note":"their_function(text, maxLen) that truncates to maxLen UTF-16 units should not hand back a string that crashes when URL-encoded or re-serialized."}]},{"id":"turkish-dotless-i-case-mapping","title":"Lowercasing a Turkish İ does not give you plain \"i\"","category":"unicode","assumption":"Lowercasing a capital letter and comparing it to the obvious ASCII spelling works the way it does for A-Z.","input":{"display":"\"\\u0130stanbul\" (capital I with dot above, U+0130) vs \"istanbul\"","description":"A user from Turkey types their city, or their own name, into a search box or signup form that lowercases input to build a case-insensitive lookup key."},"what_goes_wrong":"\"\\u0130stanbul\".lower() is not \"istanbul\" — it becomes \"i\\u0307stanbul\" (9 code points instead of 8), so the case-insensitive match against the plain spelling fails, and NFC normalization does not repair it.","why":"Python's str.lower() and JavaScript's toLowerCase() apply Unicode's default, locale-independent case mapping. The default lowercase of U+0130 (LATIN CAPITAL LETTER I WITH DOT ABOVE) is two code points, U+0069 U+0307 (\"i\" plus COMBINING DOT ABOVE), so the dot is preserved without knowing whether the text is Turkish. Turkish and Azerbaijani pair İ with i and I with ı (dotless), which needs a locale-specific mapping: JavaScript has one in toLocaleLowerCase(\"tr\"), which returns plain \"istanbul\" here, but Python's str methods take no locale. There is no precomposed lowercase \"i with dot above\" (i already has a dot), so NFC normalization afterwards does not collapse the pair back to plain \"i\".","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"import unicodedata\ns = \"\\u0130stanbul\"\nlowered = s.lower()\nprint(lowered, len(s), len(lowered))\nprint(lowered == \"istanbul\")\nprint(unicodedata.normalize(\"NFC\", lowered) == \"istanbul\")\n","expected_output":"i̇stanbul 8 9\nFalse\nFalse","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const s = \"\\u0130stanbul\";\nconst lowered = s.toLowerCase();\nconsole.log(lowered, s.length, lowered.length);\nconsole.log(lowered === \"istanbul\");\nconsole.log(lowered.normalize(\"NFC\") === \"istanbul\");\n","expected_output":"i̇stanbul 8 9\nfalse\nfalse","verified_with":"Node.js v22.22.2"}],"fix":"Do not build a uniqueness or search key by calling lower()/upper() on raw user text when the content may be Turkish or Azerbaijani. When you know the locale, use locale-aware mapping (toLocaleLowerCase(\"tr\") in JavaScript, ICU case mapping with locale \"tr\"/\"az\" in Python via a third-party binding). When you do not, use casefold() consistently on both sides, and accept that İ/I/ı/i cannot be folded correctly for every language at once.","related":["german-eszett-case-fold","nfc-nfd-same-name-two-accounts"],"sources":[{"title":"Unicode Standard: SpecialCasing.txt (default vs. Turkish/Azeri case mappings)","url":"https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt"},{"title":"Python docs: str.lower / str.upper","url":"https://docs.python.org/3/library/stdtypes.html#str.lower"},{"title":"MDN: String.prototype.toLowerCase()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase"},{"title":"MDN: String.prototype.toLocaleLowerCase()","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLocaleLowerCase"}],"tags":["username","uniqueness","login","search","i18n","normalization","validation"],"test_cases":[{"inputs":["İstanbul","İSTANBUL"],"relation":"equal","note":"Any case-insensitive key should unify these two (casefold() does). Whether \"İstanbul\" should also match plain \"istanbul\" depends on the locale: yes under Turkish rules, no under the default mapping."}]},{"id":"zalgo-combining-mark-stacking","title":"A nickname that looks like one letter can be 161 code points long","category":"unicode","assumption":"A short-looking string is a short string, so a per-character length limit also bounds how much text it can contain.","input":{"display":"\"e\" followed by a combining mark repeated 20 times (\"\\u0301\\u0310\\u0327\\u032d\\u0353\\u0338\\u035f\\u0360\" x 20) — \"Zalgo text\"","description":"A display name, comment, or review submitted with Unicode combining marks stacked onto a single base character (easy to generate with any \"Zalgo text\" web tool), occupying one visual line position but arbitrarily many code points."},"what_goes_wrong":"The string occupies roughly one glyph's space (an \"e\" under a stack of marks) but is 161 code points and 321 UTF-8 bytes. A \"nickname must be 1-20 characters\" validator that counts grapheme clusters, the measure usually recommended for user-facing length, counts it as 1 character and accepts it, while it takes far more storage than any 20-character name and can render as a tall smear over neighbouring lines.","why":"Unicode's definition of well-formed text puts no limit on how many combining marks (General Category Mn, Mc, Me) may follow one base character. Each mark is a legal code point that stacks on the previous one, and UAX #29 groups the base and all of its marks into a single grapheme cluster, so a grapheme count sees one character however many marks follow. UAX #15 defines a \"Stream-Safe Text Format\" that caps runs of non-starters at 30, but it is a recommendation that nothing enforces by default. Code-point or byte limits catch this particular string, which is why the fix combines both measures.","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"base = \"e\"\nmarks = \"\\u0301\\u0310\\u0327\\u032d\\u0353\\u0338\\u035f\\u0360\"\nzalgo = base + marks * 20\nprint(len(zalgo), len(zalgo.encode(\"utf-8\")))\n","expected_output":"161 321","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const base = \"e\";\nconst marks = \"\\u0301\\u0310\\u0327\\u032d\\u0353\\u0338\\u035f\\u0360\";\nconst zalgo = base + marks.repeat(20);\nconsole.log(zalgo.length, Buffer.byteLength(zalgo, \"utf8\"));\n","expected_output":"161 321","verified_with":"Node.js v22.22.2"}],"fix":"Enforce a limit in code points or bytes for storage as well as a grapheme-cluster limit for what the user sees, and cap the number of combining marks per base character (a handful covers real orthographies; UAX #15's Stream-Safe Text Format uses 30 as an upper bound).","related":["grapheme-vs-codepoint-length","bidi-override-filename-spoof"],"sources":[{"title":"UAX #29: Unicode Text Segmentation (grapheme clusters)","url":"https://www.unicode.org/reports/tr29/"},{"title":"UAX #15: Stream-Safe Text Format","url":"https://www.unicode.org/reports/tr15/#Stream_Safe_Text_Format"},{"title":"Big List of Naughty Strings (MIT)","url":"https://github.com/minimaxir/big-list-of-naughty-strings"}],"tags":["username","length validation","forms","display","validation","security"],"test_cases":[]},{"id":"zero-width-space-username","title":"A username with an invisible character survives trim() and slips past a block on \"admin\"","category":"unicode","assumption":"strip()/trim() removes anything that isn't visible, so a trimmed string can't contain hidden characters.","input":{"display":"\"ad\\u200bmin\" (ZERO WIDTH SPACE, U+200B, inserted in the middle) vs \"admin\"","description":"Pasted text from a chat app, a scraped web page, or a deliberately crafted signup request trying to register a name that looks reserved but technically isn't."},"what_goes_wrong":"\"ad\\u200bmin\" renders as \"admin\", is not equal to \"admin\", is one code point longer, and strip()/trim() leaves it completely unchanged because U+200B sits in the middle of the string, not at an edge, and Unicode does not even classify it as whitespace.","why":"ZERO WIDTH SPACE (U+200B) has no visible glyph; text-layout engines use it to mark an optional line break inside long words. Its General Category is Cf (format), not Zs (space separator), and it does not have the Unicode White_Space property, so Python's str.strip() and JavaScript's trim() do not treat it as whitespace, and here it is in the middle of the string anyway. ZERO WIDTH JOINER (U+200D) and ZERO WIDTH NO-BREAK SPACE / BOM (U+FEFF) work the same way in the middle of a string. (At the edges JavaScript's trim() does remove U+FEFF, because ECMAScript counts it as whitespace; Python's strip() does not.)","affects":["python","javascript"],"repros":[{"language":"python","status":"verified","code":"a = \"admin\"\nb = \"ad\\u200bmin\"\nprint(a == b, len(a), len(b))\nprint(b.strip() == a, len(b.strip()))\n","expected_output":"False 5 6\nFalse 6","verified_with":"Python 3.14.2"},{"language":"javascript","status":"verified","code":"const a = \"admin\";\nconst b = \"ad\\u200bmin\";\nconsole.log(a === b, a.length, b.length);\nconsole.log(b.trim() === a, b.trim().length);\n","expected_output":"false 5 6\nfalse 6","verified_with":"Node.js v22.22.2"}],"fix":"Strip or reject Unicode format characters (category Cf) and other invisible code points explicitly during input sanitization, not just leading/trailing whitespace — don't rely on strip()/trim() to catch them, and don't assume a blocklist check on reserved names (\"admin\", \"root\") is safe without first removing invisibles.","related":["cyrillic-homoglyph-paypal-spoof"],"sources":[{"title":"Unicode Character Database: General Category values (Cf = Format)","url":"https://www.unicode.org/reports/tr44/#General_Category_Values"},{"title":"Big List of Naughty Strings (MIT)","url":"https://github.com/minimaxir/big-list-of-naughty-strings"}],"tags":["username","uniqueness","login","security","validation","forms"],"test_cases":[{"inputs":["admin","admin"],"relation":"equal","note":"The key used for reserved-name and uniqueness checks should remove invisible format characters (category Cf) so both map to \"admin\" and the second is blocked."}]}];
|