From 5ffc7c0ead359c60a0cb5b2b4fdb522846933853 Mon Sep 17 00:00:00 2001 From: Joe Hildebrand Date: Sat, 7 Dec 2024 04:35:55 -0800 Subject: [PATCH] feat: Catch more unsafe numbers (#71) * test: Drive-by nit to fix test name When the test was copied from a previous example, the name in the comments wasn't changed. * feat: Catch more unsafe numbers Flag subnormals, numbers that are flushed to zero, and overly-large integers. Refs #68 * Rework unsafe zero. Fix code review issues. * Add a few more tests * Rework error text * Move detailed description of no-unsafe-values to readme. * fmt * Add values to errors * Make links in readme * code review issues --- README.md | 7 +- src/rules/no-unsafe-values.js | 66 +++++++++++- tests/rules/no-unsafe-values.test.js | 146 ++++++++++++++++++++++++++- 3 files changed, 216 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 26e9674..d02bd7f 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,12 @@ export default [ - `no-duplicate-keys` - warns when there are two keys in an object with the same text. - `no-empty-keys` - warns when there is a key in an object that is an empty string or contains only whitespace (note: `package-lock.json` uses empty keys intentionally) -- `no-unsafe-values` - warns on values that are unsafe for interchange, such as numbers outside safe range or lone surrogates. +- `no-unsafe-values` - warns on values that are unsafe for interchange, such + as strings with unmatched + [surrogates](https://en.wikipedia.org/wiki/UTF-16), numbers that evaluate to + Infinity, numbers that evaluate to zero unintentionally, numbers that look + like integers but are too large, and + [subnormal numbers](https://en.wikipedia.org/wiki/Subnormal_number). - `no-unnormalized-keys` - warns on keys containing [unnormalized characters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize#description). You can optionally specify the normalization form via `{ form: "form_name" }`, where `form_name` can be any of `"NFC"`, `"NFD"`, `"NFKC"`, or `"NFKD"`. - `top-level-interop` - warns when the top-level item in the document is neither an array nor an object. This can be enabled to ensure maximal interoperability with the oldest JSON parsers. diff --git a/src/rules/no-unsafe-values.js b/src/rules/no-unsafe-values.js index f341729..ed29a48 100644 --- a/src/rules/no-unsafe-values.js +++ b/src/rules/no-unsafe-values.js @@ -3,6 +3,12 @@ * @author Bradley Meck Farias */ +// RFC 8259's `number` production, as a regex. Capture the integer part +// and the fractional part. +const NUMBER = + /^-?(?0|([1-9][0-9]*))(?:\.(?[0-9]+))?(?:[eE][+-]?[0-9]+)?$/u; +const NON_ZERO = /[1-9]/u; + export default { meta: { type: /** @type {const} */ ("problem"), @@ -12,7 +18,12 @@ export default { }, messages: { - unsafeNumber: "Number outside safe range found.", + unsafeNumber: "The number '{{ value }}' will evaluate to Infinity.", + unsafeInteger: + "The integer '{{ value }}' is outside the safe integer range.", + unsafeZero: "The number '{{ value }}' will evaluate to zero.", + subnormal: + "Unexpected subnormal number '{{ value }}' found, which may cause interoperability issues.", loneSurrogate: "Lone surrogate '{{ surrogate }}' found.", }, }, @@ -20,11 +31,64 @@ export default { create(context) { return { Number(node) { + const value = context.sourceCode.getText(node); + if (Number.isFinite(node.value) !== true) { context.report({ loc: node.loc, messageId: "unsafeNumber", + data: { value }, }); + } else { + // Also matches -0, intentionally + if (node.value === 0) { + // If the value has been rounded down to 0, but there was some + // fraction or non-zero part before the e-, this is a very small + // number that doesn't fit inside an f64. + const match = value.match(NUMBER); + // assert(match, "If the regex is right, match is always truthy") + + // If any part of the number other than the exponent has a + // non-zero digit in it, this number was not intended to be + // evaluated down to a zero. + if ( + NON_ZERO.test(match.groups.int) || + NON_ZERO.test(match.groups.frac) + ) { + context.report({ + loc: node.loc, + messageId: "unsafeZero", + data: { value }, + }); + } + } else if (!/[.e]/iu.test(value)) { + // Intended to be an integer + if ( + node.value > Number.MAX_SAFE_INTEGER || + node.value < Number.MIN_SAFE_INTEGER + ) { + context.report({ + loc: node.loc, + messageId: "unsafeInteger", + data: { value }, + }); + } + } else { + // Floating point. Check for subnormal. + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setFloat64(0, node.value, false); + const asBigInt = view.getBigUint64(0, false); + // Subnormals have an 11-bit exponent of 0 and a non-zero mantissa. + if ((asBigInt & 0x7ff0000000000000n) === 0n) { + context.report({ + loc: node.loc, + messageId: "subnormal", + // Value included so that it's seen in scientific notation + data: node, + }); + } + } } }, String(node) { diff --git a/tests/rules/no-unsafe-values.test.js b/tests/rules/no-unsafe-values.test.js index 8e82e37..7e8234b 100644 --- a/tests/rules/no-unsafe-values.test.js +++ b/tests/rules/no-unsafe-values.test.js @@ -1,5 +1,5 @@ /** - * @fileoverview Tests for no-empty-keys rule. + * @fileoverview Tests for no-unsafe-values rule. * @author Bradley Meck Farias */ @@ -35,6 +35,9 @@ ruleTester.run("no-unsafe-values", rule, { }, '"🔥"', '"\\ud83d\\udd25"', + "0.00000", + "0e0000000", + "0.00000e0000", ], invalid: [ { @@ -42,6 +45,9 @@ ruleTester.run("no-unsafe-values", rule, { errors: [ { messageId: "unsafeNumber", + data: { + value: "2e308", + }, line: 1, column: 1, endLine: 1, @@ -54,6 +60,9 @@ ruleTester.run("no-unsafe-values", rule, { errors: [ { messageId: "unsafeNumber", + data: { + value: "-2e308", + }, line: 1, column: 1, endLine: 1, @@ -131,5 +140,140 @@ ruleTester.run("no-unsafe-values", rule, { }, ], }, + { + code: "1e-400", + errors: [ + { + messageId: "unsafeZero", + data: { + value: "1e-400", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 7, + }, + ], + }, + { + code: "-1e-400", + errors: [ + { + messageId: "unsafeZero", + data: { + value: "-1e-400", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + }, + ], + }, + { + code: "0.01e-400", + errors: [ + { + messageId: "unsafeZero", + data: { + value: "0.01e-400", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 10, + }, + ], + }, + { + code: "-10.2e-402", + errors: [ + { + messageId: "unsafeZero", + data: { + value: "-10.2e-402", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 11, + }, + ], + }, + { + code: `0.${"0".repeat(400)}1`, + errors: [ + { + messageId: "unsafeZero", + data: { + value: `0.${"0".repeat(400)}1`, + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 404, + }, + ], + }, + { + code: "9007199254740992", + errors: [ + { + messageId: "unsafeInteger", + data: { + value: "9007199254740992", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 17, + }, + ], + }, + { + code: "-9007199254740992", + errors: [ + { + messageId: "unsafeInteger", + data: { + value: "-9007199254740992", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 18, + }, + ], + }, + { + code: "2.2250738585072009e-308", + errors: [ + { + messageId: "subnormal", + data: { + value: "2.225073858507201e-308", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 24, + }, + ], + }, + { + code: "-2.2250738585072009e-308", + errors: [ + { + messageId: "subnormal", + data: { + value: "-2.225073858507201e-308", + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 25, + }, + ], + }, ], });