@pipeworx/mcp-edge-case-museum 0.1.0 → 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
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "windows-reserved-device-names",
|
|
3
|
-
"title": "A file named CON, NUL, or COM1 opens a device on Windows, not a file",
|
|
4
|
-
"category": "filenames",
|
|
5
|
-
"assumption": "A filename made of ordinary letters is always safe to create as a file.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"CON\", \"con.txt\", \"NUL\", \"COM1\", \"LPT1\"",
|
|
8
|
-
"description": "A user-supplied filename (upload name, export filename, log file name) that happens to match a reserved DOS device name."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"windows win32 file apis (bare device names always; name-plus-extension forms before windows 11)",
|
|
14
|
-
"ntpath (no validation)",
|
|
15
|
-
"path.win32 (no validation)"
|
|
16
|
-
],
|
|
17
|
-
"repros": [
|
|
18
|
-
{
|
|
19
|
-
"language": "python",
|
|
20
|
-
"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",
|
|
21
|
-
"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",
|
|
22
|
-
"verified_with": "Python 3.14.2"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"language": "javascript",
|
|
26
|
-
"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",
|
|
27
|
-
"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",
|
|
28
|
-
"verified_with": "Node.js v22.22.2"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"language": "windows",
|
|
32
|
-
"status": "documented",
|
|
33
|
-
"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.",
|
|
34
|
-
"source": "https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"
|
|
35
|
-
}
|
|
36
|
-
],
|
|
37
|
-
"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.",
|
|
38
|
-
"related": [],
|
|
39
|
-
"sources": [
|
|
40
|
-
{
|
|
41
|
-
"title": "Microsoft Learn: Naming Files, Paths, and Namespaces",
|
|
42
|
-
"url": "https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "Microsoft Learn: File path formats on Windows systems (Handle legacy devices)",
|
|
46
|
-
"url": "https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats"
|
|
47
|
-
}
|
|
48
|
-
],
|
|
49
|
-
"tags": [
|
|
50
|
-
"filename",
|
|
51
|
-
"path",
|
|
52
|
-
"upload",
|
|
53
|
-
"validation",
|
|
54
|
-
"security",
|
|
55
|
-
"windows"
|
|
56
|
-
],
|
|
57
|
-
"test_cases": [
|
|
58
|
-
{
|
|
59
|
-
"inputs": [
|
|
60
|
-
"CON.txt"
|
|
61
|
-
],
|
|
62
|
-
"relation": "no_crash",
|
|
63
|
-
"note": "Your filename validator should flag this (a reserved device name with an extension) before any file is created on Windows, not after."
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
"inputs": [
|
|
67
|
-
"nul"
|
|
68
|
-
],
|
|
69
|
-
"relation": "no_crash",
|
|
70
|
-
"note": "Reserved names are case-insensitive; a validator should flag lowercase 'nul' too."
|
|
71
|
-
}
|
|
72
|
-
]
|
|
73
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "windows-trailing-dot-space-stripped",
|
|
3
|
-
"title": "\"report.\" and \"report \" are the same file as \"report\" on Windows, but not to your path library",
|
|
4
|
-
"category": "filenames",
|
|
5
|
-
"assumption": "Two filename strings that differ only by a trailing dot or space refer to two different files.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"report.\" and \"report \" (both compared against \"report\")",
|
|
8
|
-
"description": "A filename built by concatenating a title with a trailing space, or a name ending in a stray period from a stripped extension."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"windows win32 path normalization (strips trailing dots and spaces unless the path starts with \\\\?\\)",
|
|
14
|
-
"pathlib.PureWindowsPath (no stripping)",
|
|
15
|
-
"path.win32 (no stripping)"
|
|
16
|
-
],
|
|
17
|
-
"repros": [
|
|
18
|
-
{
|
|
19
|
-
"language": "python",
|
|
20
|
-
"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",
|
|
21
|
-
"expected_output": "report. == report ? False\n'report ' == 'report' ? False",
|
|
22
|
-
"verified_with": "Python 3.14.2"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"language": "javascript",
|
|
26
|
-
"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",
|
|
27
|
-
"expected_output": "report. == report ? false\n\"report \" == \"report\" ? false",
|
|
28
|
-
"verified_with": "Node.js v22.22.2"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"language": "windows",
|
|
32
|
-
"status": "documented",
|
|
33
|
-
"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\".",
|
|
34
|
-
"source": "https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats"
|
|
35
|
-
}
|
|
36
|
-
],
|
|
37
|
-
"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.",
|
|
38
|
-
"related": [
|
|
39
|
-
"case-insensitive-filesystem-pure-path-mismatch"
|
|
40
|
-
],
|
|
41
|
-
"sources": [
|
|
42
|
-
{
|
|
43
|
-
"title": "Microsoft Learn: File path formats on Windows systems (Trim characters)",
|
|
44
|
-
"url": "https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats"
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
"title": "Microsoft Learn: Naming Files, Paths, and Namespaces",
|
|
48
|
-
"url": "https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"
|
|
49
|
-
}
|
|
50
|
-
],
|
|
51
|
-
"tags": [
|
|
52
|
-
"filename",
|
|
53
|
-
"path",
|
|
54
|
-
"validation",
|
|
55
|
-
"upload",
|
|
56
|
-
"security",
|
|
57
|
-
"windows"
|
|
58
|
-
],
|
|
59
|
-
"test_cases": [
|
|
60
|
-
{
|
|
61
|
-
"inputs": [
|
|
62
|
-
"report.",
|
|
63
|
-
"report"
|
|
64
|
-
],
|
|
65
|
-
"relation": "equal",
|
|
66
|
-
"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."
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
"inputs": [
|
|
70
|
-
"report ",
|
|
71
|
-
"report"
|
|
72
|
-
],
|
|
73
|
-
"relation": "equal",
|
|
74
|
-
"note": "Same for a trailing space."
|
|
75
|
-
}
|
|
76
|
-
]
|
|
77
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "decimal-from-float-vs-string",
|
|
3
|
-
"title": "Decimal(0.1) is not Decimal(\"0.1\") - switching to Decimal doesn't fix the bug if you build it from a float",
|
|
4
|
-
"category": "float-money",
|
|
5
|
-
"assumption": "Wrapping a problematic float in decimal.Decimal() makes it exact, regardless of how the Decimal was constructed.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "decimal.Decimal(0.1) vs decimal.Decimal(\"0.1\")",
|
|
8
|
-
"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."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python"
|
|
14
|
-
],
|
|
15
|
-
"repros": [
|
|
16
|
-
{
|
|
17
|
-
"language": "python",
|
|
18
|
-
"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",
|
|
19
|
-
"expected_output": "0.1000000000000000055511151231257827021181583404541015625\n0.1\nFalse\nFalse\nTrue",
|
|
20
|
-
"verified_with": "Python 3.14.2"
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"language": "javascript",
|
|
24
|
-
"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",
|
|
25
|
-
"expected_output": "0.1\n0.1000000000000000055511151231257827021181583404541015625\n0.3000000000000000444089209850062616169452667236328125000",
|
|
26
|
-
"verified_with": "Node.js v22.22.2"
|
|
27
|
-
}
|
|
28
|
-
],
|
|
29
|
-
"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.",
|
|
30
|
-
"related": [
|
|
31
|
-
"float-anchor-0-1-plus-0-2",
|
|
32
|
-
"python-round-2-675-float-imprecision"
|
|
33
|
-
],
|
|
34
|
-
"sources": [
|
|
35
|
-
{
|
|
36
|
-
"title": "Python docs: decimal - Decimal fixed point and floating point arithmetic",
|
|
37
|
-
"url": "https://docs.python.org/3/library/decimal.html"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"title": "Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations",
|
|
41
|
-
"url": "https://docs.python.org/3/tutorial/floatingpoint.html"
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
"title": "TC39 proposal: Decimal",
|
|
45
|
-
"url": "https://github.com/tc39/proposal-decimal"
|
|
46
|
-
}
|
|
47
|
-
],
|
|
48
|
-
"tags": [
|
|
49
|
-
"money",
|
|
50
|
-
"currency",
|
|
51
|
-
"price",
|
|
52
|
-
"accounting",
|
|
53
|
-
"invoice",
|
|
54
|
-
"rounding"
|
|
55
|
-
],
|
|
56
|
-
"test_cases": [
|
|
57
|
-
{
|
|
58
|
-
"inputs": [
|
|
59
|
-
"0.1",
|
|
60
|
-
0.1
|
|
61
|
-
],
|
|
62
|
-
"relation": "no_crash",
|
|
63
|
-
"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."
|
|
64
|
-
}
|
|
65
|
-
]
|
|
66
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "float-anchor-0-1-plus-0-2",
|
|
3
|
-
"title": "0.1 + 0.2 comes out to 0.30000000000000004",
|
|
4
|
-
"category": "float-money",
|
|
5
|
-
"assumption": "Adding 0.1 and 0.2 gives exactly 0.3.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "0.1 + 0.2 (two IEEE-754 double-precision literals)",
|
|
8
|
-
"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."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript",
|
|
15
|
-
"java",
|
|
16
|
-
"c",
|
|
17
|
-
"any IEEE-754 double"
|
|
18
|
-
],
|
|
19
|
-
"repros": [
|
|
20
|
-
{
|
|
21
|
-
"language": "python",
|
|
22
|
-
"code": "a = 0.1 + 0.2\nprint(a)\nprint(a == 0.3)\nprint(f\"{a:.20f}\")\n",
|
|
23
|
-
"expected_output": "0.30000000000000004\nFalse\n0.30000000000000004441",
|
|
24
|
-
"verified_with": "Python 3.14.2"
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
"language": "javascript",
|
|
28
|
-
"code": "const a = 0.1 + 0.2;\nconsole.log(a);\nconsole.log(a === 0.3);\nconsole.log(a.toFixed(20));\n",
|
|
29
|
-
"expected_output": "0.30000000000000004\nfalse\n0.30000000000000004441",
|
|
30
|
-
"verified_with": "Node.js v22.22.2"
|
|
31
|
-
}
|
|
32
|
-
],
|
|
33
|
-
"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.",
|
|
34
|
-
"related": [
|
|
35
|
-
"sum-ten-dimes-not-a-dollar",
|
|
36
|
-
"decimal-from-float-vs-string"
|
|
37
|
-
],
|
|
38
|
-
"sources": [
|
|
39
|
-
{
|
|
40
|
-
"title": "Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations",
|
|
41
|
-
"url": "https://docs.python.org/3/tutorial/floatingpoint.html"
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
"title": "What Every Computer Scientist Should Know About Floating-Point Arithmetic (Goldberg, hosted by Oracle)",
|
|
45
|
-
"url": "https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html"
|
|
46
|
-
}
|
|
47
|
-
],
|
|
48
|
-
"tags": [
|
|
49
|
-
"money",
|
|
50
|
-
"rounding",
|
|
51
|
-
"currency",
|
|
52
|
-
"accounting",
|
|
53
|
-
"price",
|
|
54
|
-
"comparison",
|
|
55
|
-
"validation"
|
|
56
|
-
],
|
|
57
|
-
"test_cases": [
|
|
58
|
-
{
|
|
59
|
-
"inputs": [
|
|
60
|
-
0.1,
|
|
61
|
-
0.2
|
|
62
|
-
],
|
|
63
|
-
"relation": "no_crash",
|
|
64
|
-
"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."
|
|
65
|
-
}
|
|
66
|
-
]
|
|
67
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "intl-number-format-de-de-roundtrip-misparsed",
|
|
3
|
-
"title": "Format a price for Germany, then parseFloat it back, and 1234.56 becomes 1.234",
|
|
4
|
-
"category": "float-money",
|
|
5
|
-
"assumption": "A number you formatted for display with Intl.NumberFormat can be read back with parseFloat to recover the same value.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "parseFloat(new Intl.NumberFormat(\"de-DE\").format(1234.56))",
|
|
8
|
-
"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."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"javascript"
|
|
14
|
-
],
|
|
15
|
-
"repros": [
|
|
16
|
-
{
|
|
17
|
-
"language": "javascript",
|
|
18
|
-
"code": "const formatted = new Intl.NumberFormat(\"de-DE\").format(1234.56);\nconsole.log(formatted);\nconsole.log(parseFloat(formatted));\n",
|
|
19
|
-
"expected_output": "1.234,56\n1.234",
|
|
20
|
-
"verified_with": "Node.js v22.22.2"
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"language": "python",
|
|
24
|
-
"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",
|
|
25
|
-
"expected_output": "ValueError: could not convert string to float: '1.234,56'",
|
|
26
|
-
"verified_with": "Python 3.14.2"
|
|
27
|
-
}
|
|
28
|
-
],
|
|
29
|
-
"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.",
|
|
30
|
-
"related": [
|
|
31
|
-
"parsefloat-comma-thousands-separator"
|
|
32
|
-
],
|
|
33
|
-
"sources": [
|
|
34
|
-
{
|
|
35
|
-
"title": "MDN: Intl.NumberFormat",
|
|
36
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat"
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"title": "MDN: parseFloat()",
|
|
40
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseFloat"
|
|
41
|
-
}
|
|
42
|
-
],
|
|
43
|
-
"tags": [
|
|
44
|
-
"money",
|
|
45
|
-
"price",
|
|
46
|
-
"currency",
|
|
47
|
-
"i18n",
|
|
48
|
-
"forms",
|
|
49
|
-
"validation"
|
|
50
|
-
],
|
|
51
|
-
"test_cases": [
|
|
52
|
-
{
|
|
53
|
-
"inputs": [
|
|
54
|
-
"1.234,56",
|
|
55
|
-
"de-DE"
|
|
56
|
-
],
|
|
57
|
-
"relation": "returns",
|
|
58
|
-
"expected": 1234.56,
|
|
59
|
-
"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."
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "iso4217-minor-units-not-always-two",
|
|
3
|
-
"title": "Converting dollars to \"cents\" by multiplying by 100 is wrong for most of the world's currencies",
|
|
4
|
-
"category": "float-money",
|
|
5
|
-
"assumption": "Every currency has exactly 2 decimal places, so multiplying a major-unit amount by 100 always gives the correct minor-unit (cents) integer.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "100 * amountInJPY (0 decimal places), 100 * amountInBHD (3 decimal places), 100 * amountInCLF (4 decimal places)",
|
|
8
|
-
"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."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"javascript",
|
|
14
|
-
"python",
|
|
15
|
-
"any payment integration that hardcodes a decimal-to-minor-unit conversion"
|
|
16
|
-
],
|
|
17
|
-
"repros": [
|
|
18
|
-
{
|
|
19
|
-
"language": "javascript",
|
|
20
|
-
"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",
|
|
21
|
-
"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)",
|
|
22
|
-
"verified_with": "Node.js v22.22.2"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"language": "python",
|
|
26
|
-
"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",
|
|
27
|
-
"expected_output": "JPY 1000 buggy: 100000 correct: 1000\nBHD 1.5 buggy: 150 correct: 1500",
|
|
28
|
-
"verified_with": "Python 3.14.2"
|
|
29
|
-
}
|
|
30
|
-
],
|
|
31
|
-
"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.",
|
|
32
|
-
"related": [
|
|
33
|
-
"float-anchor-0-1-plus-0-2"
|
|
34
|
-
],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "ISO 4217 currency codes and minor unit data (SIX Group, official ISO 4217 maintenance agency)",
|
|
38
|
-
"url": "https://www.six-group.com/en/products-services/financial-information/data-standards.html"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "MDN: Intl.NumberFormat.prototype.resolvedOptions()",
|
|
42
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/resolvedOptions"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "Unicode CLDR supplemental currency data (digits used by ICU/Intl)",
|
|
46
|
-
"url": "https://github.com/unicode-org/cldr/blob/main/common/supplemental/supplementalData.xml"
|
|
47
|
-
}
|
|
48
|
-
],
|
|
49
|
-
"tags": [
|
|
50
|
-
"money",
|
|
51
|
-
"currency",
|
|
52
|
-
"price",
|
|
53
|
-
"invoice",
|
|
54
|
-
"accounting",
|
|
55
|
-
"i18n",
|
|
56
|
-
"validation"
|
|
57
|
-
],
|
|
58
|
-
"test_cases": [
|
|
59
|
-
{
|
|
60
|
-
"inputs": [
|
|
61
|
-
1000,
|
|
62
|
-
"JPY"
|
|
63
|
-
],
|
|
64
|
-
"relation": "returns",
|
|
65
|
-
"expected": 1000,
|
|
66
|
-
"note": "their_function(amount, currency) that converts to minor units should return 1000 for ¥1000, not 100000."
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
"inputs": [
|
|
70
|
-
1.5,
|
|
71
|
-
"BHD"
|
|
72
|
-
],
|
|
73
|
-
"relation": "returns",
|
|
74
|
-
"expected": 1500,
|
|
75
|
-
"note": "their_function(amount, currency) should return 1500 fils for BD1.500, not 150."
|
|
76
|
-
}
|
|
77
|
-
]
|
|
78
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "math-abs-negative-zero",
|
|
3
|
-
"title": "-0 === 0 is true, yet 1 / -0 is -Infinity: JavaScript has a negative zero that equality and printing both hide",
|
|
4
|
-
"category": "float-money",
|
|
5
|
-
"assumption": "A number that compares equal to 0 is simply zero; there is nothing more to know about it.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "-0 === 0 (true) but Object.is(-0, 0) === false, and 1 / -0 === -Infinity; Math.abs(-0) is +0",
|
|
8
|
-
"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."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"javascript",
|
|
14
|
-
"python"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "javascript",
|
|
19
|
-
"code": "console.log(Math.abs(-0));\nconsole.log(-0 === 0, Object.is(-0, 0));\nconsole.log(1 / -0, 1 / 0);\n",
|
|
20
|
-
"expected_output": "0\ntrue false\n-Infinity Infinity",
|
|
21
|
-
"verified_with": "Node.js v22.22.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "python",
|
|
25
|
-
"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",
|
|
26
|
-
"expected_output": "0.0\nTrue\n-1.0\nZeroDivisionError: division by zero",
|
|
27
|
-
"verified_with": "Python 3.14.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"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.",
|
|
31
|
-
"related": [],
|
|
32
|
-
"sources": [
|
|
33
|
-
{
|
|
34
|
-
"title": "IEEE 754-2019 (floating-point arithmetic, signed zero)",
|
|
35
|
-
"url": "https://ieeexplore.ieee.org/document/8766229"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"title": "MDN: Object.is()",
|
|
39
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"title": "ECMA-262: IsStrictlyEqual",
|
|
43
|
-
"url": "https://tc39.es/ecma262/#sec-isstrictlyequal"
|
|
44
|
-
}
|
|
45
|
-
],
|
|
46
|
-
"tags": [
|
|
47
|
-
"validation",
|
|
48
|
-
"comparison"
|
|
49
|
-
],
|
|
50
|
-
"test_cases": []
|
|
51
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "max-safe-integer-plus-two",
|
|
3
|
-
"title": "2**53 + 1 is a different number in Python than it is once it becomes a float",
|
|
4
|
-
"category": "float-money",
|
|
5
|
-
"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.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "2**53 + 1 as a Python int vs as a JS Number/float",
|
|
8
|
-
"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."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"javascript",
|
|
14
|
-
"any IEEE-754 double used to hold an integer id or count"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "a = 2**53\nb = 2**53 + 1\nprint(a == b)\nprint(float(a) == float(b))\n",
|
|
20
|
-
"expected_output": "False\nTrue",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"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",
|
|
26
|
-
"expected_output": "true\nfalse false\n9007199254740991",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"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.",
|
|
31
|
-
"related": [
|
|
32
|
-
"float-anchor-0-1-plus-0-2",
|
|
33
|
-
"json-big-integer-precision-loss"
|
|
34
|
-
],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "MDN: Number.MAX_SAFE_INTEGER",
|
|
38
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "ECMA-262: Number.isSafeInteger",
|
|
42
|
-
"url": "https://tc39.es/ecma262/#sec-number.issafeinteger"
|
|
43
|
-
}
|
|
44
|
-
],
|
|
45
|
-
"tags": [
|
|
46
|
-
"money",
|
|
47
|
-
"accounting",
|
|
48
|
-
"invoice",
|
|
49
|
-
"id",
|
|
50
|
-
"database"
|
|
51
|
-
],
|
|
52
|
-
"test_cases": [
|
|
53
|
-
{
|
|
54
|
-
"inputs": [
|
|
55
|
-
9007199254740992,
|
|
56
|
-
9007199254740993
|
|
57
|
-
],
|
|
58
|
-
"relation": "not_equal",
|
|
59
|
-
"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."
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "naive-loop-sum-order-dependence",
|
|
3
|
-
"title": "The same three numbers sum to 0.0 or 1.0 depending only on the order you add them in",
|
|
4
|
-
"category": "float-money",
|
|
5
|
-
"assumption": "Addition is associative, so summing a list of amounts gives the same total no matter what order you add them in.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "a loop totaling [1e16, 1.0, -1e16] vs the same three values reordered to [1e16, -1e16, 1.0]",
|
|
8
|
-
"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`."
|
|
9
|
-
},
|
|
10
|
-
"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.",
|
|
11
|
-
"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).",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript",
|
|
15
|
-
"any naive left-to-right float accumulation"
|
|
16
|
-
],
|
|
17
|
-
"repros": [
|
|
18
|
-
{
|
|
19
|
-
"language": "python",
|
|
20
|
-
"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",
|
|
21
|
-
"expected_output": "0.0 1.0\n1.0 1.0",
|
|
22
|
-
"verified_with": "Python 3.14.2"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"language": "javascript",
|
|
26
|
-
"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",
|
|
27
|
-
"expected_output": "0 1",
|
|
28
|
-
"verified_with": "Node.js v22.22.2"
|
|
29
|
-
}
|
|
30
|
-
],
|
|
31
|
-
"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.",
|
|
32
|
-
"related": [
|
|
33
|
-
"sum-ten-dimes-not-a-dollar",
|
|
34
|
-
"float-anchor-0-1-plus-0-2"
|
|
35
|
-
],
|
|
36
|
-
"sources": [
|
|
37
|
-
{
|
|
38
|
-
"title": "Python docs: math.fsum()",
|
|
39
|
-
"url": "https://docs.python.org/3/library/math.html#math.fsum"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"title": "What Every Computer Scientist Should Know About Floating-Point Arithmetic (Goldberg, hosted by Oracle)",
|
|
43
|
-
"url": "https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html"
|
|
44
|
-
}
|
|
45
|
-
],
|
|
46
|
-
"tags": [
|
|
47
|
-
"money",
|
|
48
|
-
"accounting",
|
|
49
|
-
"invoice",
|
|
50
|
-
"price",
|
|
51
|
-
"rounding"
|
|
52
|
-
],
|
|
53
|
-
"test_cases": [
|
|
54
|
-
{
|
|
55
|
-
"inputs": [
|
|
56
|
-
[
|
|
57
|
-
1e+16,
|
|
58
|
-
1.0,
|
|
59
|
-
-1e+16
|
|
60
|
-
],
|
|
61
|
-
[
|
|
62
|
-
1e+16,
|
|
63
|
-
-1e+16,
|
|
64
|
-
1.0
|
|
65
|
-
]
|
|
66
|
-
],
|
|
67
|
-
"relation": "equal",
|
|
68
|
-
"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."
|
|
69
|
-
}
|
|
70
|
-
]
|
|
71
|
-
}
|