@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.
Files changed (135) hide show
  1. package/README.md +17 -12
  2. package/bin/cli.js +55 -11
  3. package/lib/index.js +333 -0
  4. package/package.json +17 -16
  5. package/server.json +1 -1
  6. package/tools.json +98 -0
  7. package/src/bake.mjs +0 -112
  8. package/src/ecm-index-data.ts +0 -15
  9. package/src/exhibits/datetime/add-24-hours-vs-add-1-day-across-dst.json +0 -51
  10. package/src/exhibits/datetime/add-365-days-to-feb-29-not-next-feb-29.json +0 -49
  11. package/src/exhibits/datetime/age-calculator-feb29-birthday-crash-vs-silent.json +0 -52
  12. package/src/exhibits/datetime/century-leap-year-1900-not-leap.json +0 -68
  13. package/src/exhibits/datetime/datetime-utcnow-naive-deprecated.json +0 -39
  14. package/src/exhibits/datetime/dst-fall-back-ambiguous-fold.json +0 -51
  15. package/src/exhibits/datetime/dst-spring-forward-nonexistent-time.json +0 -57
  16. package/src/exhibits/datetime/fractional-utc-offsets-kathmandu-kolkata.json +0 -50
  17. package/src/exhibits/datetime/iso-week-year-vs-calendar-year.json +0 -48
  18. package/src/exhibits/datetime/js-date-field-overflow-rollover.json +0 -49
  19. package/src/exhibits/datetime/leap-second-rejected-vs-silently-invalid.json +0 -57
  20. package/src/exhibits/datetime/lord-howe-30-minute-dst.json +0 -47
  21. package/src/exhibits/datetime/midnight-that-doesnt-exist-sao-paulo.json +0 -53
  22. package/src/exhibits/datetime/python-naive-aware-comparison-typeerror.json +0 -46
  23. package/src/exhibits/datetime/samoa-skipped-day-2011.json +0 -50
  24. package/src/exhibits/datetime/southern-hemisphere-dst-reversed.json +0 -50
  25. package/src/exhibits/datetime/two-digit-year-century-pivot-mismatch.json +0 -49
  26. package/src/exhibits/datetime/year-2038-int32-overflow.json +0 -52
  27. package/src/exhibits/filenames/absolute-second-argument-resets-join.json +0 -62
  28. package/src/exhibits/filenames/bidi-override-extension-spoof.json +0 -58
  29. package/src/exhibits/filenames/case-insensitive-filesystem-pure-path-mismatch.json +0 -74
  30. package/src/exhibits/filenames/dotfile-has-no-extension.json +0 -61
  31. package/src/exhibits/filenames/extension-trap-double-extension.json +0 -62
  32. package/src/exhibits/filenames/filename-length-limits-bytes-not-chars.json +0 -66
  33. package/src/exhibits/filenames/leading-dash-filename-parsed-as-flag.json +0 -67
  34. package/src/exhibits/filenames/nfc-nfd-filename-lookup-mismatch.json +0 -68
  35. package/src/exhibits/filenames/ntfs-alternate-data-stream-colon.json +0 -59
  36. package/src/exhibits/filenames/ntpath-splitdrive-unc-paths.json +0 -58
  37. package/src/exhibits/filenames/nul-byte-in-filename-raises-at-open.json +0 -60
  38. package/src/exhibits/filenames/path-join-escapes-the-base-directory.json +0 -63
  39. package/src/exhibits/filenames/windows-backslash-traversal-ntpath.json +0 -62
  40. package/src/exhibits/filenames/windows-reserved-device-names.json +0 -73
  41. package/src/exhibits/filenames/windows-trailing-dot-space-stripped.json +0 -77
  42. package/src/exhibits/float-money/decimal-from-float-vs-string.json +0 -66
  43. package/src/exhibits/float-money/float-anchor-0-1-plus-0-2.json +0 -67
  44. package/src/exhibits/float-money/intl-number-format-de-de-roundtrip-misparsed.json +0 -62
  45. package/src/exhibits/float-money/iso4217-minor-units-not-always-two.json +0 -78
  46. package/src/exhibits/float-money/math-abs-negative-zero.json +0 -51
  47. package/src/exhibits/float-money/max-safe-integer-plus-two.json +0 -62
  48. package/src/exhibits/float-money/naive-loop-sum-order-dependence.json +0 -71
  49. package/src/exhibits/float-money/nan-breaks-equality-and-max-min.json +0 -70
  50. package/src/exhibits/float-money/negative-zero-balance-formatting.json +0 -74
  51. package/src/exhibits/float-money/number-epsilon-false-positive-small-magnitude.json +0 -52
  52. package/src/exhibits/float-money/parsefloat-comma-thousands-separator.json +0 -66
  53. package/src/exhibits/float-money/python-banker-rounding-vs-js-round-half-up.json +0 -65
  54. package/src/exhibits/float-money/python-round-2-675-float-imprecision.json +0 -64
  55. package/src/exhibits/float-money/splitting-100-dollars-three-ways-leaves-a-cent.json +0 -63
  56. package/src/exhibits/float-money/sum-ten-dimes-not-a-dollar.json +0 -77
  57. package/src/exhibits/float-money/tofixed-1-005-rounds-down-not-up.json +0 -63
  58. package/src/exhibits/float-money/tofixed-2-345-inconsistent-rounding.json +0 -64
  59. package/src/exhibits/integers/array-index-above-2-32-2-not-an-index.json +0 -42
  60. package/src/exhibits/integers/bigint-number-mixing-typeerror.json +0 -49
  61. package/src/exhibits/integers/bitwise-32-bit-truncation.json +0 -56
  62. package/src/exhibits/integers/bool-is-int-subclass-dict-key-collision.json +0 -58
  63. package/src/exhibits/integers/floor-div-mod-sign-python-vs-js.json +0 -48
  64. package/src/exhibits/integers/json-parse-loses-id-precision.json +0 -58
  65. package/src/exhibits/integers/max-safe-integer-plus-one-equals-plus-two.json +0 -58
  66. package/src/exhibits/integers/number-empty-and-whitespace-string-equals-zero.json +0 -55
  67. package/src/exhibits/integers/parseint-null-radix36-digit-soup.json +0 -57
  68. package/src/exhibits/integers/parseint-number-argument-scientific-notation.json +0 -48
  69. package/src/exhibits/integers/parseint-stops-at-first-invalid-char-vs-number.json +0 -58
  70. package/src/exhibits/integers/python-int-accepts-fullwidth-unicode-digits.json +0 -56
  71. package/src/exhibits/integers/python-int-accepts-underscores-and-whitespace.json +0 -48
  72. package/src/exhibits/integers/python-unbounded-int-vs-fixed-width-wrap.json +0 -49
  73. package/src/exhibits/names-addresses/address-with-no-street-number.json +0 -54
  74. package/src/exhibits/names-addresses/ascii-regex-rejects-accented-and-hyphenated-names.json +0 -55
  75. package/src/exhibits/names-addresses/house-number-suffix-collapsed-by-digit-extraction.json +0 -55
  76. package/src/exhibits/names-addresses/mononym-fails-first-last-required.json +0 -56
  77. package/src/exhibits/names-addresses/naive-lowercase-breaks-greek-final-sigma.json +0 -64
  78. package/src/exhibits/names-addresses/null-surname-coerced-to-null-value.json +0 -58
  79. package/src/exhibits/names-addresses/single-letter-surname-fails-min-length.json +0 -55
  80. package/src/exhibits/names-addresses/sort-by-surname-particle-convention-mismatch.json +0 -57
  81. package/src/exhibits/names-addresses/spanish-double-surname-treated-as-single.json +0 -55
  82. package/src/exhibits/names-addresses/split-surname-on-last-space-loses-particle.json +0 -59
  83. package/src/exhibits/names-addresses/title-case-mangles-mixed-case-and-apostrophes.json +0 -60
  84. package/src/exhibits/names-addresses/uk-canada-postal-code-assumed-numeric.json +0 -55
  85. package/src/exhibits/names-addresses/us-zip-code-leading-zero-lost-as-integer.json +0 -58
  86. package/src/exhibits/names-addresses/varchar-truncation-splits-name-mid-grapheme.json +0 -63
  87. package/src/exhibits/parsing/csv-crlf-trailing-blank-line.json +0 -87
  88. package/src/exhibits/parsing/csv-doubled-quote-escaping.json +0 -70
  89. package/src/exhibits/parsing/csv-formula-injection-excel.json +0 -71
  90. package/src/exhibits/parsing/csv-quoted-field-with-comma.json +0 -70
  91. package/src/exhibits/parsing/csv-quoted-field-with-newline.json +0 -70
  92. package/src/exhibits/parsing/csv-semicolon-delimited-european-excel.json +0 -70
  93. package/src/exhibits/parsing/csv-utf8-bom-first-header.json +0 -74
  94. package/src/exhibits/parsing/email-regex-rejects-valid-apostrophe-address.json +0 -68
  95. package/src/exhibits/parsing/json-big-integer-precision-loss.json +0 -65
  96. package/src/exhibits/parsing/json-duplicate-keys-last-wins.json +0 -64
  97. package/src/exhibits/parsing/json-lone-surrogate-roundtrip.json +0 -58
  98. package/src/exhibits/parsing/json-nan-infinity-not-valid-json.json +0 -54
  99. package/src/exhibits/parsing/url-backslash-treated-as-slash-whatwg.json +0 -60
  100. package/src/exhibits/parsing/url-urlparse-never-raises-on-garbage.json +0 -60
  101. package/src/exhibits/parsing/url-userinfo-trusted-looking-host.json +0 -60
  102. package/src/exhibits/sorting/accented-letters-sort-after-z.json +0 -63
  103. package/src/exhibits/sorting/byte-order-caps-before-lowercase.json +0 -66
  104. package/src/exhibits/sorting/case-insensitive-sort-lower-vs-casefold.json +0 -56
  105. package/src/exhibits/sorting/comparator-returns-boolean-js.json +0 -49
  106. package/src/exhibits/sorting/js-array-sort-stringifies-numbers.json +0 -68
  107. package/src/exhibits/sorting/locale-collation-de-vs-sv.json +0 -54
  108. package/src/exhibits/sorting/localecompare-numeric-option.json +0 -50
  109. package/src/exhibits/sorting/nan-in-sort.json +0 -59
  110. package/src/exhibits/sorting/natural-sort-breaks-lexicographic.json +0 -63
  111. package/src/exhibits/sorting/postgres-collation-documented.json +0 -61
  112. package/src/exhibits/sorting/python-cannot-compare-str-int.json +0 -70
  113. package/src/exhibits/sorting/reverse-sort-vs-reverse-after-sort-ties.json +0 -48
  114. package/src/exhibits/sorting/sorting-mixed-none-null.json +0 -56
  115. package/src/exhibits/sorting/typed-array-sort-is-numeric-by-default.json +0 -48
  116. package/src/exhibits/unicode/arabic-indic-digit-parses.json +0 -73
  117. package/src/exhibits/unicode/bidi-override-filename-spoof.json +0 -62
  118. package/src/exhibits/unicode/cyrillic-homoglyph-paypal-spoof.json +0 -67
  119. package/src/exhibits/unicode/fullwidth-bypasses-keyword-filter.json +0 -61
  120. package/src/exhibits/unicode/german-eszett-case-fold.json +0 -72
  121. package/src/exhibits/unicode/grapheme-vs-codepoint-length.json +0 -57
  122. package/src/exhibits/unicode/greek-final-sigma-casefold.json +0 -54
  123. package/src/exhibits/unicode/js-word-regex-ascii-only.json +0 -64
  124. package/src/exhibits/unicode/nfc-nfd-same-name-two-accounts.json +0 -70
  125. package/src/exhibits/unicode/nfkc-compatibility-folding.json +0 -63
  126. package/src/exhibits/unicode/truncation-orphans-combining-mark.json +0 -62
  127. package/src/exhibits/unicode/truncation-splits-surrogate-pair.json +0 -65
  128. package/src/exhibits/unicode/turkish-dotless-i-case-mapping.json +0 -72
  129. package/src/exhibits/unicode/zalgo-combining-mark-stacking.json +0 -57
  130. package/src/exhibits/unicode/zero-width-space-username.json +0 -62
  131. package/src/harness.mjs +0 -331
  132. package/src/index.ts +0 -686
  133. package/src/server.ts +0 -49
  134. package/src/verified.json +0 -977
  135. package/tsconfig.json +0 -18
@@ -1,70 +0,0 @@
1
- {
2
- "id": "nan-breaks-equality-and-max-min",
3
- "title": "NaN != NaN, and Python's max() picks a different \"largest\" value depending on where the NaN sits in the list",
4
- "category": "float-money",
5
- "assumption": "max() and min() over a list of numbers return the mathematically largest/smallest value, regardless of order, and a value always equals itself.",
6
- "input": {
7
- "display": "max([float('nan'), 1]) vs max([1, float('nan')])",
8
- "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."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "python",
14
- "javascript"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "nan = float(\"nan\")\nprint(nan != nan)\nprint(max([nan, 1]), max([1, nan]))\nprint(min([nan, 1]), min([1, nan]))\n",
20
- "expected_output": "True\nnan 1\nnan 1",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "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",
26
- "expected_output": "true\nNaN NaN\nNaN NaN",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "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.",
31
- "related": [],
32
- "sources": [
33
- {
34
- "title": "Python docs: max()",
35
- "url": "https://docs.python.org/3/library/functions.html#max"
36
- },
37
- {
38
- "title": "MDN: Math.max()",
39
- "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max"
40
- },
41
- {
42
- "title": "IEEE 754-2019 (comparison predicates involving NaN, summarized in Python's math module docs)",
43
- "url": "https://docs.python.org/3/library/math.html#math.isnan"
44
- }
45
- ],
46
- "tags": [
47
- "money",
48
- "price",
49
- "accounting",
50
- "comparison",
51
- "validation",
52
- "sorting"
53
- ],
54
- "test_cases": [
55
- {
56
- "inputs": [
57
- [
58
- "NaN",
59
- "1"
60
- ],
61
- [
62
- "1",
63
- "NaN"
64
- ]
65
- ],
66
- "relation": "equal",
67
- "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."
68
- }
69
- ]
70
- }
@@ -1,74 +0,0 @@
1
- {
2
- "id": "negative-zero-balance-formatting",
3
- "title": "A $0.00 balance can print as \"-0.00\" - and Python and JavaScript disagree on when",
4
- "category": "float-money",
5
- "assumption": "A balance that computes to zero always displays as \"0.00\", regardless of how it was computed.",
6
- "input": {
7
- "display": "0.0 * -1, or -0.0, formatted as currency",
8
- "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."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "python",
14
- "javascript"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "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",
20
- "expected_output": "-0.0\nTrue\n-0.00\n-1.0\n0.00 -0.00 0.00",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "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",
26
- "expected_output": "-0\nfalse true\n0.00\n0\n-0.00 true",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "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.",
31
- "related": [
32
- "splitting-100-dollars-three-ways-leaves-a-cent"
33
- ],
34
- "sources": [
35
- {
36
- "title": "IEEE 754-2019 (summary of signed zero via Python docs)",
37
- "url": "https://docs.python.org/3/tutorial/floatingpoint.html"
38
- },
39
- {
40
- "title": "MDN: Object.is()",
41
- "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is"
42
- },
43
- {
44
- "title": "ECMA-262: Number::toString",
45
- "url": "https://tc39.es/ecma262/#sec-numeric-types-number-tostring"
46
- }
47
- ],
48
- "tags": [
49
- "money",
50
- "accounting",
51
- "invoice",
52
- "price",
53
- "display",
54
- "rounding"
55
- ],
56
- "test_cases": [
57
- {
58
- "inputs": [
59
- 0,
60
- 0
61
- ],
62
- "relation": "equal",
63
- "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."
64
- },
65
- {
66
- "inputs": [
67
- -0.004
68
- ],
69
- "relation": "returns",
70
- "expected": "0.00",
71
- "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)."
72
- }
73
- ]
74
- }
@@ -1,52 +0,0 @@
1
- {
2
- "id": "number-epsilon-false-positive-small-magnitude",
3
- "title": "Number.EPSILON as a tolerance says 1e-20 and 2e-20 are equal, even though one is exactly double the other",
4
- "category": "float-money",
5
- "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.",
6
- "input": {
7
- "display": "Math.abs(1e-20 - 2e-20) <= Number.EPSILON",
8
- "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."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "javascript",
14
- "python",
15
- "any code using machine epsilon as an absolute tolerance"
16
- ],
17
- "repros": [
18
- {
19
- "language": "javascript",
20
- "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",
21
- "expected_output": "false\n1e-20 2.220446049250313e-16\ntrue",
22
- "verified_with": "Node.js v22.22.2"
23
- },
24
- {
25
- "language": "python",
26
- "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",
27
- "expected_output": "False\n1e-20 2.220446049250313e-16\nTrue",
28
- "verified_with": "Python 3.14.2"
29
- }
30
- ],
31
- "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.",
32
- "related": [
33
- "float-anchor-0-1-plus-0-2"
34
- ],
35
- "sources": [
36
- {
37
- "title": "MDN: Number.EPSILON",
38
- "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/EPSILON"
39
- },
40
- {
41
- "title": "Python docs: sys.float_info",
42
- "url": "https://docs.python.org/3/library/sys.html#sys.float_info"
43
- }
44
- ],
45
- "tags": [
46
- "money",
47
- "price",
48
- "comparison",
49
- "validation",
50
- "accounting"
51
- ]
52
- }
@@ -1,66 +0,0 @@
1
- {
2
- "id": "parsefloat-comma-thousands-separator",
3
- "title": "parseFloat(\"1,234.56\") is 1 - not 1234.56, not NaN",
4
- "category": "float-money",
5
- "assumption": "parseFloat on a reasonable-looking number string either parses the whole number correctly or fails loudly.",
6
- "input": {
7
- "display": "parseFloat(\"1,234.56\")",
8
- "description": "A price pasted from a spreadsheet, invoice, or a US-formatted input field (\"$1,234.56\") run through parseFloat to get a number."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "javascript"
14
- ],
15
- "repros": [
16
- {
17
- "language": "javascript",
18
- "code": "console.log(parseFloat(\"1,234.56\"));\nconsole.log(parseFloat(\"$1,234.56\"));\nconsole.log(Number(\"1,234.56\"));\n",
19
- "expected_output": "1\nNaN\nNaN",
20
- "verified_with": "Node.js v22.22.2"
21
- },
22
- {
23
- "language": "python",
24
- "code": "try:\n print(float(\"1,234.56\"))\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 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.",
30
- "related": [
31
- "intl-number-format-de-de-roundtrip-misparsed"
32
- ],
33
- "sources": [
34
- {
35
- "title": "MDN: parseFloat()",
36
- "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseFloat"
37
- },
38
- {
39
- "title": "ECMA-262: parseFloat",
40
- "url": "https://tc39.es/ecma262/#sec-parsefloat-string"
41
- },
42
- {
43
- "title": "Python docs: float()",
44
- "url": "https://docs.python.org/3/library/functions.html#float"
45
- }
46
- ],
47
- "tags": [
48
- "money",
49
- "price",
50
- "invoice",
51
- "currency",
52
- "i18n",
53
- "validation",
54
- "forms"
55
- ],
56
- "test_cases": [
57
- {
58
- "inputs": [
59
- "1,234.56"
60
- ],
61
- "relation": "returns",
62
- "expected": 1234.56,
63
- "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."
64
- }
65
- ]
66
- }
@@ -1,65 +0,0 @@
1
- {
2
- "id": "python-banker-rounding-vs-js-round-half-up",
3
- "title": "round(2.5) is 2 in Python but Math.round(2.5) is 3 in JavaScript",
4
- "category": "float-money",
5
- "assumption": "Rounding a value that is exactly halfway between two integers always rounds up, in every language.",
6
- "input": {
7
- "display": "round(2.5) in Python 3 vs Math.round(2.5) in JavaScript, and round(-2.5) vs Math.round(-2.5)",
8
- "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."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "python",
14
- "javascript"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "print(round(2.5), round(3.5), round(-2.5), round(-3.5))\n",
20
- "expected_output": "2 4 -2 -4",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "code": "console.log(Math.round(2.5), Math.round(3.5), Math.round(-2.5), Math.round(-3.5));\n",
26
- "expected_output": "3 4 -2 -3",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "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.",
31
- "related": [
32
- "python-round-2-675-float-imprecision",
33
- "decimal-from-float-vs-string"
34
- ],
35
- "sources": [
36
- {
37
- "title": "Python docs: round()",
38
- "url": "https://docs.python.org/3/library/functions.html#round"
39
- },
40
- {
41
- "title": "MDN: Math.round()",
42
- "url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round"
43
- },
44
- {
45
- "title": "ECMA-262: Math.round",
46
- "url": "https://tc39.es/ecma262/#sec-math.round"
47
- }
48
- ],
49
- "tags": [
50
- "money",
51
- "rounding",
52
- "price",
53
- "accounting",
54
- "invoice"
55
- ],
56
- "test_cases": [
57
- {
58
- "inputs": [
59
- 2.5
60
- ],
61
- "relation": "no_crash",
62
- "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."
63
- }
64
- ]
65
- }
@@ -1,64 +0,0 @@
1
- {
2
- "id": "python-round-2-675-float-imprecision",
3
- "title": "round(2.675, 2) is 2.67 in Python, not 2.68",
4
- "category": "float-money",
5
- "assumption": "round(x, 2) rounds a price to the nearest cent the way a calculator would, based on the decimal digits you wrote.",
6
- "input": {
7
- "display": "round(2.675, 2)",
8
- "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."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "python",
14
- "any IEEE-754 double rounded by value rather than by decimal text"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "x = 2.675\nprint(round(x, 2))\nprint(f\"{x:.20f}\")\nimport decimal\nprint(decimal.Decimal(x))\n",
20
- "expected_output": "2.67\n2.67499999999999982236\n2.67499999999999982236431605997495353221893310546875",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "code": "const x = 2.675;\nconsole.log(Number(x.toFixed(2)));\nconsole.log(x.toFixed(20));\n",
26
- "expected_output": "2.67\n2.67499999999999982236",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "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.",
31
- "related": [
32
- "tofixed-1-005-rounds-down-not-up",
33
- "python-banker-rounding-vs-js-round-half-up",
34
- "decimal-from-float-vs-string"
35
- ],
36
- "sources": [
37
- {
38
- "title": "Python docs: round()",
39
- "url": "https://docs.python.org/3/library/functions.html#round"
40
- },
41
- {
42
- "title": "Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations",
43
- "url": "https://docs.python.org/3/tutorial/floatingpoint.html"
44
- }
45
- ],
46
- "tags": [
47
- "money",
48
- "rounding",
49
- "price",
50
- "currency",
51
- "invoice",
52
- "accounting"
53
- ],
54
- "test_cases": [
55
- {
56
- "inputs": [
57
- "2.675"
58
- ],
59
- "relation": "returns",
60
- "expected": "2.68",
61
- "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."
62
- }
63
- ]
64
- }
@@ -1,63 +0,0 @@
1
- {
2
- "id": "splitting-100-dollars-three-ways-leaves-a-cent",
3
- "title": "Splitting $100.00 three ways and rounding each share loses a cent",
4
- "category": "float-money",
5
- "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.",
6
- "input": {
7
- "display": "10000 cents / 3, rounded, times 3",
8
- "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."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "python",
14
- "javascript",
15
- "any integer or float division used to split a shared amount"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "total_cents = 10000\nn = 3\nshare = round(total_cents / n)\nprint(share, share * n, total_cents - share * n)\n",
21
- "expected_output": "3333 9999 1",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "const totalCents = 10000;\nconst n = 3;\nconst share = Math.round(totalCents / n);\nconsole.log(share, share * n, totalCents - share * n);\n",
27
- "expected_output": "3333 9999 1",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "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.",
32
- "related": [
33
- "negative-zero-balance-formatting"
34
- ],
35
- "sources": [
36
- {
37
- "title": "Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations",
38
- "url": "https://docs.python.org/3/tutorial/floatingpoint.html"
39
- }
40
- ],
41
- "tags": [
42
- "money",
43
- "rounding",
44
- "accounting",
45
- "invoice",
46
- "price"
47
- ],
48
- "test_cases": [
49
- {
50
- "inputs": [
51
- 10000,
52
- 3
53
- ],
54
- "relation": "returns",
55
- "expected": [
56
- 3334,
57
- 3333,
58
- 3333
59
- ],
60
- "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."
61
- }
62
- ]
63
- }
@@ -1,77 +0,0 @@
1
- {
2
- "id": "sum-ten-dimes-not-a-dollar",
3
- "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",
4
- "category": "float-money",
5
- "assumption": "Accumulating ten $0.10 charges one at a time gives exactly $1.00, and Python's sum() is just a loop in disguise.",
6
- "input": {
7
- "display": "a loop adding 0.1 ten times, vs. sum([0.1] * 10)",
8
- "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."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "python",
14
- "javascript"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "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",
20
- "expected_output": "0.9999999999999999 False\n1.0 True",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "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",
26
- "expected_output": "0.9999999999999999 false\n0.9999999999999999 false",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "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.",
31
- "related": [
32
- "float-anchor-0-1-plus-0-2",
33
- "naive-loop-sum-order-dependence"
34
- ],
35
- "sources": [
36
- {
37
- "title": "Python docs: sum()",
38
- "url": "https://docs.python.org/3/library/functions.html#sum"
39
- },
40
- {
41
- "title": "What's New In Python 3.12",
42
- "url": "https://docs.python.org/3/whatsnew/3.12.html"
43
- },
44
- {
45
- "title": "Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations",
46
- "url": "https://docs.python.org/3/tutorial/floatingpoint.html"
47
- }
48
- ],
49
- "tags": [
50
- "money",
51
- "rounding",
52
- "currency",
53
- "accounting",
54
- "price",
55
- "invoice"
56
- ],
57
- "test_cases": [
58
- {
59
- "inputs": [
60
- [
61
- 0.1,
62
- 0.1,
63
- 0.1,
64
- 0.1,
65
- 0.1,
66
- 0.1,
67
- 0.1,
68
- 0.1,
69
- 0.1,
70
- 0.1
71
- ]
72
- ],
73
- "relation": "no_crash",
74
- "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."
75
- }
76
- ]
77
- }
@@ -1,63 +0,0 @@
1
- {
2
- "id": "tofixed-1-005-rounds-down-not-up",
3
- "title": "(1.005).toFixed(2) is \"1.00\", not \"1.01\"",
4
- "category": "float-money",
5
- "assumption": "toFixed(2) rounds a price to the nearest cent using ordinary round-half-up rules.",
6
- "input": {
7
- "display": "(1.005).toFixed(2)",
8
- "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."
9
- },
10
- "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.",
11
- "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.",
12
- "affects": [
13
- "javascript",
14
- "any IEEE-754 double formatted by rounding its true binary value"
15
- ],
16
- "repros": [
17
- {
18
- "language": "javascript",
19
- "code": "console.log((1.005).toFixed(2));\nconsole.log((1.005).toFixed(20));\n",
20
- "expected_output": "1.00\n1.00499999999999989342",
21
- "verified_with": "Node.js v22.22.2"
22
- },
23
- {
24
- "language": "python",
25
- "code": "x = 1.005\nprint(f\"{x:.2f}\")\nprint(f\"{x:.20f}\")\n",
26
- "expected_output": "1.00\n1.00499999999999989342",
27
- "verified_with": "Python 3.14.2"
28
- }
29
- ],
30
- "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.",
31
- "related": [
32
- "tofixed-2-345-inconsistent-rounding",
33
- "python-round-2-675-float-imprecision",
34
- "decimal-from-float-vs-string"
35
- ],
36
- "sources": [
37
- {
38
- "title": "ECMA-262: Number.prototype.toFixed",
39
- "url": "https://tc39.es/ecma262/#sec-number.prototype.tofixed"
40
- },
41
- {
42
- "title": "Python docs: 15.1. Floating-Point Arithmetic: Issues and Limitations",
43
- "url": "https://docs.python.org/3/tutorial/floatingpoint.html"
44
- }
45
- ],
46
- "tags": [
47
- "money",
48
- "rounding",
49
- "price",
50
- "currency",
51
- "invoice",
52
- "display"
53
- ],
54
- "test_cases": [
55
- {
56
- "inputs": [
57
- 1.005
58
- ],
59
- "relation": "no_crash",
60
- "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."
61
- }
62
- ]
63
- }