@williamthorsen/toolbelt.numbers 6.0.1 → 7.0.1

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.
@@ -0,0 +1,305 @@
1
+ /** @noformat — @generated. Do not edit. Compiled by rdy. */
2
+ /* eslint-disable */
3
+ export const __readyupVersion = "0.32.0";
4
+
5
+
6
+ // ../adoption/src/conventions/path-predicates.ts
7
+ var BIN_DIRECTORY = /(?:^|\/)bin\//;
8
+ var JS_TS_EXTENSION = /\.[cm]?[jt]sx?$/;
9
+ var TEST_DIRECTORY = /(?:^|\/)__tests__\//;
10
+ var TEST_SUFFIX = /\.(?:spec|test)\.[cm]?[jt]sx?$/;
11
+ function isAdoptableSource(path) {
12
+ return isJsTsSource(path) && !isBinWrapper(path) && !isTestFile(path) && !isInTestDirectory(path);
13
+ }
14
+ function isBinWrapper(path) {
15
+ return BIN_DIRECTORY.test(path);
16
+ }
17
+ function isInTestDirectory(path) {
18
+ return TEST_DIRECTORY.test(path);
19
+ }
20
+ function isJsTsSource(path) {
21
+ return JS_TS_EXTENSION.test(path);
22
+ }
23
+ function isTestFile(path) {
24
+ return TEST_SUFFIX.test(path);
25
+ }
26
+
27
+ // ../adoption/src/conventions/site-handoffs.ts
28
+ var SUBSCRIPT_TAIL = /(?<token>[\w$]+|[)\]'"`])\s?(?:\?\.)?\s?\[\s?$/;
29
+ var EXPRESSION_KEYWORDS = /* @__PURE__ */ new Set([
30
+ "await",
31
+ "case",
32
+ "delete",
33
+ "in",
34
+ "new",
35
+ "of",
36
+ "return",
37
+ "typeof",
38
+ "void",
39
+ "yield"
40
+ ]);
41
+ function isArraySubscript(before) {
42
+ const token = SUBSCRIPT_TAIL.exec(before)?.groups?.["token"];
43
+ return token !== void 0 && !EXPRESSION_KEYWORDS.has(token);
44
+ }
45
+
46
+ // ../adoption/src/kits/defineAdoptionKit.ts
47
+ import { defineRdyKit } from "readyup";
48
+ import {
49
+ buildFindingReport,
50
+ countPackageUsage,
51
+ readTrackedSources
52
+ } from "readyup/check-utils";
53
+ var NOT_A_REPO = "the project is not a git working tree, and these checks read the files git tracks";
54
+ var NOTHING_TO_REPORT = { findings: [] };
55
+ function defineAdoptionKit(spec) {
56
+ assertCheckIdsAreUnique();
57
+ const cache = {};
58
+ const adoptedPackage = { exportNames: spec.exportNames, packageName: spec.packageName };
59
+ return defineRdyKit({
60
+ description: spec.description,
61
+ defaultSeverity: "warn",
62
+ checklists: [
63
+ {
64
+ name: "adoption",
65
+ checks: spec.checks.map((check) => ({
66
+ name: check.name,
67
+ id: check.id,
68
+ ...check.severity !== void 0 && { severity: check.severity },
69
+ skip: skipUnlessProjectHoldsSources,
70
+ check: () => reportKinds(check.kinds),
71
+ fix: check.fix
72
+ }))
73
+ }
74
+ ]
75
+ });
76
+ function assertCheckIdsAreUnique() {
77
+ const seen = /* @__PURE__ */ new Set();
78
+ const duplicated = /* @__PURE__ */ new Set();
79
+ for (const { id } of spec.checks) {
80
+ if (seen.has(id)) duplicated.add(id);
81
+ seen.add(id);
82
+ }
83
+ if (duplicated.size > 0) {
84
+ const ids = [...duplicated].toSorted().join(", ");
85
+ throw new Error(`${spec.packageName}'s kit gives one id to more than one check: ${ids}`);
86
+ }
87
+ }
88
+ function loadSummary() {
89
+ cache.summary ??= readProject();
90
+ return cache.summary;
91
+ }
92
+ async function readProject() {
93
+ const sources = await readTrackedSources(spec.pathFilter);
94
+ if (sources === void 0) return void 0;
95
+ return {
96
+ adoptedCount: countPackageUsage(sources, adoptedPackage),
97
+ findings: sources.flatMap((source) => spec.detect(source.text).map((site) => ({ ...site, path: source.path }))),
98
+ sources
99
+ };
100
+ }
101
+ async function reportKinds(kinds) {
102
+ const summary = await loadSummary();
103
+ if (summary === void 0) return NOTHING_TO_REPORT;
104
+ return buildFindingReport({
105
+ adoptedCount: summary.adoptedCount,
106
+ findings: summary.findings,
107
+ ownImplementation: { ...adoptedPackage, sources: summary.sources },
108
+ shouldReport: (finding) => kinds.includes(finding.kind)
109
+ });
110
+ }
111
+ async function skipUnlessProjectHoldsSources() {
112
+ const summary = await loadSummary();
113
+ if (summary === void 0) return NOT_A_REPO;
114
+ return summary.sources.length === 0 ? spec.noSourcesReason : false;
115
+ }
116
+ }
117
+
118
+ // ../adoption/src/portable/condenseWhitespace.ts
119
+ function condenseWhitespace(text) {
120
+ return text.replaceAll(/\s+/g, " ");
121
+ }
122
+
123
+ // ../adoption/src/portable/readBalancedGroup.ts
124
+ var PARENTHESES = { close: ")", open: "(" };
125
+ function readBalancedGroup(source, from, delimiters) {
126
+ const start = source.indexOf(delimiters.open, from);
127
+ if (start === -1) return void 0;
128
+ let depth = 0;
129
+ for (let index = start; index < source.length; index += 1) {
130
+ if (source[index] === delimiters.open) depth += 1;
131
+ else if (source[index] === delimiters.close) {
132
+ depth -= 1;
133
+ if (depth === 0) return { end: index + 1, start };
134
+ }
135
+ }
136
+ return void 0;
137
+ }
138
+
139
+ // ../adoption/src/portable/readAnchoredWindow.ts
140
+ function readAnchoredWindow(source, offset, lengths) {
141
+ return {
142
+ after: condenseWhitespace(source.slice(offset, offset + lengths.lookahead)),
143
+ before: condenseWhitespace(source.slice(Math.max(0, offset - lengths.lookbehind), offset))
144
+ };
145
+ }
146
+
147
+ // ../adoption/src/mod.ts
148
+ import { blankNonCode, getLineAtOffset } from "readyup/check-utils";
149
+
150
+ // src/readiness/adoptedExports.ts
151
+ var ADOPTED_EXPORTS = [
152
+ "clamp",
153
+ "generateRandom",
154
+ "IntSeededRng",
155
+ "isIntegerString",
156
+ "isNumericString",
157
+ "makeRng",
158
+ "pickInteger",
159
+ "round",
160
+ "safeParseInteger",
161
+ "safeParseNumber",
162
+ "scale",
163
+ "SeededRng"
164
+ ];
165
+
166
+ // src/readiness/listClampNestLines.ts
167
+ var BOUNDING_CALL = /\bMath\.(?<name>max|min)\s*\(/g;
168
+ var CLOSERS = ")]}";
169
+ var OPENERS = "([{";
170
+ var NESTED_HEAD = { max: /^Math\.max\s*\(/, min: /^Math\.min\s*\(/ };
171
+ var OPPOSITE = { max: "min", min: "max" };
172
+ function listClampNestLines(source) {
173
+ const lines = [];
174
+ let claimedUntil = 0;
175
+ for (const match of source.matchAll(BOUNDING_CALL)) {
176
+ if (match.index < claimedUntil) continue;
177
+ const name = readBoundingName(match.groups?.["name"]);
178
+ if (name === void 0) continue;
179
+ const group = readBalancedGroup(source, match.index, PARENTHESES);
180
+ if (group === void 0) continue;
181
+ const args = listTopLevelArguments(source.slice(group.start + 1, group.end - 1));
182
+ if (args.length !== 2 || args.every((argument) => !isBoundingCall(argument, OPPOSITE[name]))) continue;
183
+ claimedUntil = group.end;
184
+ lines.push(getLineAtOffset(source, match.index));
185
+ }
186
+ return lines;
187
+ }
188
+ function isBoundingCall(argument, name) {
189
+ const text = argument.trim();
190
+ if (!NESTED_HEAD[name].test(text)) return false;
191
+ const group = readBalancedGroup(text, 0, PARENTHESES);
192
+ if (group === void 0 || group.end !== text.length) return false;
193
+ return listTopLevelArguments(text.slice(group.start + 1, group.end - 1)).length === 2;
194
+ }
195
+ function listTopLevelArguments(inner) {
196
+ const args = [];
197
+ let depth = 0;
198
+ let start = 0;
199
+ for (let index = 0; index < inner.length; index += 1) {
200
+ const char = inner[index];
201
+ if (char === void 0) continue;
202
+ if (OPENERS.includes(char)) depth += 1;
203
+ else if (CLOSERS.includes(char)) depth -= 1;
204
+ else if (char === "," && depth === 0) {
205
+ args.push(inner.slice(start, index));
206
+ start = index + 1;
207
+ }
208
+ }
209
+ args.push(inner.slice(start));
210
+ const last = args.at(-1);
211
+ if (args.length > 1 && last !== void 0 && last.trim() === "") args.pop();
212
+ return args;
213
+ }
214
+ function readBoundingName(name) {
215
+ return name === "max" || name === "min" ? name : void 0;
216
+ }
217
+
218
+ // src/readiness/listRandomIntegerLines.ts
219
+ var FLOORED_RANDOM = /\bMath\.floor\s*\(\s*Math\.random\s*\(\s*\)\s*\*/g;
220
+ var WINDOW = { lookahead: 0, lookbehind: 80 };
221
+ function listRandomIntegerLines(source) {
222
+ const lines = [];
223
+ for (const match of source.matchAll(FLOORED_RANDOM)) {
224
+ const { before } = readAnchoredWindow(source, match.index, WINDOW);
225
+ if (isArraySubscript(before)) continue;
226
+ lines.push(getLineAtOffset(source, match.index));
227
+ }
228
+ return lines;
229
+ }
230
+
231
+ // src/readiness/listRoundScaleLines.ts
232
+ var POWER_OF_TEN = String.raw`10\s*\*\*\s*[\w$]+|10+`;
233
+ var ROUND_CALL = /\bMath\.round\s*\(/g;
234
+ var TRAILING_FACTOR = new RegExp(String.raw`\*\s*(?<factor>${POWER_OF_TEN})\s*,?\s*$`);
235
+ var LEADING_DIVISOR = new RegExp(String.raw`^\s*/\s*(?<divisor>${POWER_OF_TEN})(?![\w$.])`);
236
+ function listRoundScaleLines(source) {
237
+ const lines = [];
238
+ for (const match of source.matchAll(ROUND_CALL)) {
239
+ const group = readBalancedGroup(source, match.index, PARENTHESES);
240
+ if (group === void 0) continue;
241
+ const factor = TRAILING_FACTOR.exec(source.slice(group.start + 1, group.end - 1))?.groups?.["factor"];
242
+ if (factor === void 0) continue;
243
+ const divisor = LEADING_DIVISOR.exec(source.slice(group.end))?.groups?.["divisor"];
244
+ if (divisor === void 0 || !isSameFactor(factor, divisor)) continue;
245
+ lines.push(getLineAtOffset(source, match.index));
246
+ }
247
+ return lines;
248
+ }
249
+ function isSameFactor(factor, divisor) {
250
+ return factor.replaceAll(/\s+/g, "") === divisor.replaceAll(/\s+/g, "");
251
+ }
252
+
253
+ // src/readiness/listMathIdioms.ts
254
+ function listMathIdioms(source) {
255
+ const code = blankNonCode(source);
256
+ const sites = [
257
+ ...toSites("clamp-nest", listClampNestLines(code)),
258
+ ...toSites("random-integer", listRandomIntegerLines(code)),
259
+ ...toSites("round-scale", listRoundScaleLines(code))
260
+ ];
261
+ return sites.toSorted((a, b) => a.line - b.line);
262
+ }
263
+ function toSites(kind, lines) {
264
+ return lines.map((line) => ({ kind, line }));
265
+ }
266
+
267
+ // .readyup/kits/default.ts
268
+ var PACKAGE_NAME = "@williamthorsen/toolbelt.numbers";
269
+ var README_URL = "https://github.com/williamthorsen/toolbelt/tree/main/packages/numbers#readme";
270
+ var default_default = defineAdoptionKit({
271
+ description: `Adoption checks for a project consuming ${PACKAGE_NAME}`,
272
+ detect: listMathIdioms,
273
+ exportNames: ADOPTED_EXPORTS,
274
+ noSourcesReason: "the project holds no JavaScript or TypeScript sources outside the exempt paths",
275
+ packageName: PACKAGE_NAME,
276
+ // A test computes these values deliberately, and a bootstrap wrapper's hand-rolled arithmetic is what keeps
277
+ // its build-first message alive through an incomplete install.
278
+ pathFilter: isAdoptableSource,
279
+ checks: [
280
+ {
281
+ name: "No source clamps a value by hand",
282
+ id: "no-hand-rolled-clamp",
283
+ kinds: ["clamp-nest"],
284
+ severity: "recommend",
285
+ fix: `Replace each expression named above with clamp from ${PACKAGE_NAME}/candidate, called as clamp(value, { min, max }). It is not a silent substitution: clamp throws a RangeError on a reversed range or a NaN bound, where the nested Math calls return a value for both. Reference: ${README_URL}`
286
+ },
287
+ {
288
+ name: "No source rounds to decimal places by hand",
289
+ id: "no-hand-rolled-round",
290
+ kinds: ["round-scale"],
291
+ severity: "recommend",
292
+ fix: `Replace each expression named above with round from ${PACKAGE_NAME}/candidate, called as round(value, places). The substitution is exact: round scales by the same power of ten these sites write out. Reference: ${README_URL}`
293
+ },
294
+ {
295
+ name: "No source derives a random integer by hand",
296
+ id: "no-hand-rolled-random-integer",
297
+ kinds: ["random-integer"],
298
+ severity: "recommend",
299
+ fix: `Replace each expression named above with pickInteger from ${PACKAGE_NAME}/candidate, which also takes a seed. Mind the bound: Math.floor(Math.random() * N) stops at N - 1, where pickInteger's max is inclusive, so the replacement is pickInteger({ max: N - 1 }). A site indexing an array is left to toolbelt.arrays, whose pickItem covers it. Reference: ${README_URL}`
300
+ }
301
+ ]
302
+ });
303
+ export {
304
+ default_default as default
305
+ };
@@ -0,0 +1,90 @@
1
+ {
2
+ "version": 1,
3
+ "kits": [
4
+ {
5
+ "checklists": [
6
+ "adoption"
7
+ ],
8
+ "description": "Adoption checks for a project consuming @williamthorsen/toolbelt.numbers",
9
+ "esbuildVersion": "0.28.2",
10
+ "inputs": [
11
+ {
12
+ "hash": "9cbd3541",
13
+ "kind": "module",
14
+ "path": "../../adoption/src/conventions/path-predicates.ts"
15
+ },
16
+ {
17
+ "hash": "c7d90b66",
18
+ "kind": "module",
19
+ "path": "../../adoption/src/conventions/site-handoffs.ts"
20
+ },
21
+ {
22
+ "hash": "781c3f53",
23
+ "kind": "module",
24
+ "path": "../../adoption/src/kits/defineAdoptionKit.ts"
25
+ },
26
+ {
27
+ "hash": "6e5a8dda",
28
+ "kind": "module",
29
+ "path": "../../adoption/src/mod.ts"
30
+ },
31
+ {
32
+ "hash": "e8d0444d",
33
+ "kind": "module",
34
+ "path": "../../adoption/src/portable/condenseWhitespace.ts"
35
+ },
36
+ {
37
+ "hash": "b6d99ce7",
38
+ "kind": "module",
39
+ "path": "../../adoption/src/portable/listFunctionBodies.ts"
40
+ },
41
+ {
42
+ "hash": "4d2d8e11",
43
+ "kind": "module",
44
+ "path": "../../adoption/src/portable/readAnchoredWindow.ts"
45
+ },
46
+ {
47
+ "hash": "498e3ca4",
48
+ "kind": "module",
49
+ "path": "../../adoption/src/portable/readBalancedGroup.ts"
50
+ },
51
+ {
52
+ "hash": "e6df5682",
53
+ "kind": "module",
54
+ "path": "kits/default.ts"
55
+ },
56
+ {
57
+ "hash": "f3d9105b",
58
+ "kind": "module",
59
+ "path": "../src/readiness/adoptedExports.ts"
60
+ },
61
+ {
62
+ "hash": "54ee66e5",
63
+ "kind": "module",
64
+ "path": "../src/readiness/listClampNestLines.ts"
65
+ },
66
+ {
67
+ "hash": "47d35285",
68
+ "kind": "module",
69
+ "path": "../src/readiness/listMathIdioms.ts"
70
+ },
71
+ {
72
+ "hash": "fa0a23b7",
73
+ "kind": "module",
74
+ "path": "../src/readiness/listRandomIntegerLines.ts"
75
+ },
76
+ {
77
+ "hash": "ae0ac903",
78
+ "kind": "module",
79
+ "path": "../src/readiness/listRoundScaleLines.ts"
80
+ }
81
+ ],
82
+ "name": "default",
83
+ "path": "kits/default.js",
84
+ "readyupVersion": "0.32.0",
85
+ "source": "kits/default.ts",
86
+ "sourceHash": "e6df5682",
87
+ "targetHash": "5f2a7b74"
88
+ }
89
+ ]
90
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,60 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 7.0.1 — 2026-08-24
6
+
7
+ ### Bug fixes
8
+
9
+ - Stop the adoption kits from blanking code after `++`, `!`, and keyword-named members (#208)
10
+
11
+ Fixes an issue where the `errors`, `numbers`, and `vitest` adoption kits stopped reading a line's code after a postfix `++`, a non-null `!`, or a property spelled like a keyword. The source blanker each kit runs before its anchor scan read the `/` that followed as the opening of a regular expression and blanked to the line's next `/`, so a replaceable idiom written after one of the three went unreported.
12
+
13
+ `@williamthorsen/toolbelt.adoption` now takes `blankNonCode` and `getLineAtOffset` from `readyup/check-utils` in place of the copies it held, which had fallen behind readyup's on those three cases.
14
+
15
+ ### Internal
16
+
17
+ - Migrate the adoption kits onto FindingOutcome and add check ids (#217)
18
+
19
+ Upgrades `readyup` to 0.32.0 and migrates `defineAdoptionKit` onto the `FindingOutcome` that `buildFindingReport` returns as of 0.31.0.
20
+
21
+ Gives every adoption check an `id`, which a consumer's `rdy-ignore` pragma names to suppress that one check; a pragma naming none still silences every check on the line. The kits of `@williamthorsen/toolbelt.errors`, `@williamthorsen/toolbelt.numbers`, and `@williamthorsen/toolbelt.vitest` each declare ids, and `defineAdoptionKit` refuses a kit that gives one id to two checks.
22
+
23
+ - Hold this repo to its own adoption kits and exempt each package's implementation (#218)
24
+
25
+ Removes the exemption that kept this repository out of its own adoption kits. `rdy run --packages` now covers this repo like any other project.
26
+
27
+ Each package's own implementation stays exempt, but that exemption now covers the function alone rather than the whole repository.
28
+
29
+ ## 7.0.0 — 2026-08-21
30
+
31
+ ### Features
32
+
33
+ - 🚨 **Breaking:** Promote clamp to the candidate tier with a stricter bounds contract (#185)
34
+
35
+ Promotes `clamp` to `@williamthorsen/toolbelt.numbers/candidate` and tightens its bounds contract: A `NaN` bound now throws a `RangeError`, where it previously returned `NaN` silently. A `NaN` value still passes through. Publishes the bounds type as `ClampBounds`, whose optional properties admit `undefined`, so a bound that may be absent typechecks under `exactOptionalPropertyTypes`.
36
+
37
+ Migration: `clamp` is no longer exported from the `/draft` subpath; consumers import it from `/candidate`.
38
+
39
+ - Add a ReadyUp adoption kit to `numbers` (#188)
40
+
41
+ Adds a ReadyUp adoption kit to `@williamthorsen/toolbelt.numbers`. When run against a project, the kit identifies hand-rolled code that can be replaced by the package's `clamp`, `round`, or `pickInteger` functions.
42
+
43
+ ### Bug fixes
44
+
45
+ - Blank comments and literals before a detector reads a source (#191)
46
+
47
+ Fixes the issue that the `errors`, `numbers`, and `vitest` adoption kits' detectors did not distinguish code from comments and literals, and so flagged a pattern written in a comment or a string as a candidate replacement site. Each detector now blanks every comment, string, template literal, and regular expression before its anchor scan, leaving interpolated expressions intact.
48
+
49
+ ### Refactoring
50
+
51
+ - Break the workspace dependency cycle by making `adoption` a leaf (#195)
52
+
53
+ Fixes a cyclic dependency among packages in the repo. `packages/adoption` is now a workspace leaf: It declares no workspace dependency, and its test scaffolding is held to node builtins. A new root test fails on any cycle in the workspace dependency graph.
54
+
55
+ ### Dependencies
56
+
57
+ - Upgrade all deps to latest version
58
+
5
59
  ## 6.0.1 — 2026-08-13
6
60
 
7
61
  ### Tooling
package/README.md CHANGED
@@ -2,8 +2,112 @@
2
2
 
3
3
  Utility functions for working with numbers.
4
4
 
5
- <!-- section:release-notes --><!-- /section:release-notes -->
5
+ <!-- section:release-notes -->
6
+ ## Release notes — v7.0.1 (2026-08-24)
7
+
8
+ ### Bug fixes
9
+
10
+ - Stop the adoption kits from blanking code after `++`, `!`, and keyword-named members (#208)
11
+
12
+ Fixes an issue where the `errors`, `numbers`, and `vitest` adoption kits stopped reading a line's code after a postfix `++`, a non-null `!`, or a property spelled like a keyword. The source blanker each kit runs before its anchor scan read the `/` that followed as the opening of a regular expression and blanked to the line's next `/`, so a replaceable idiom written after one of the three went unreported.
13
+
14
+ `@williamthorsen/toolbelt.adoption` now takes `blankNonCode` and `getLineAtOffset` from `readyup/check-utils` in place of the copies it held, which had fallen behind readyup's on those three cases.
15
+
16
+ ### Internal
17
+
18
+ - Migrate the adoption kits onto FindingOutcome and add check ids (#217)
19
+
20
+ Upgrades `readyup` to 0.32.0 and migrates `defineAdoptionKit` onto the `FindingOutcome` that `buildFindingReport` returns as of 0.31.0.
21
+
22
+ Gives every adoption check an `id`, which a consumer's `rdy-ignore` pragma names to suppress that one check; a pragma naming none still silences every check on the line. The kits of `@williamthorsen/toolbelt.errors`, `@williamthorsen/toolbelt.numbers`, and `@williamthorsen/toolbelt.vitest` each declare ids, and `defineAdoptionKit` refuses a kit that gives one id to two checks.
23
+
24
+ - Hold this repo to its own adoption kits and exempt each package's implementation (#218)
25
+
26
+ Removes the exemption that kept this repository out of its own adoption kits. `rdy run --packages` now covers this repo like any other project.
27
+
28
+ Each package's own implementation stays exempt, but that exemption now covers the function alone rather than the whole repository.
29
+ <!-- /section:release-notes -->
6
30
 
7
31
  ## Installation
8
32
 
33
+ ```sh
34
+ pnpm add @williamthorsen/toolbelt.numbers
35
+ ```
36
+
9
37
  Requires Node.js 24 or later.
38
+
39
+ `clamp`, `round`, and `pickInteger` are candidate tier: imported from `@williamthorsen/toolbelt.numbers/candidate` rather than the package root, and subject to change.
40
+
41
+ ## `clamp`
42
+
43
+ ```ts
44
+ clamp(value: number, bounds: { min?: number; max?: number }): number;
45
+ ```
46
+
47
+ Returns the value constrained to the inclusive bounds; an omitted bound leaves that side unconstrained. A reversed range or a `NaN` bound throws a `RangeError`, where the `Math.max(min, Math.min(max, value))` idiom it replaces returns a value for both. A `NaN` value passes through.
48
+
49
+ ```ts
50
+ import { clamp } from '@williamthorsen/toolbelt.numbers/candidate';
51
+
52
+ clamp(15, { min: 0, max: 10 }); // 10
53
+ clamp(-5, { min: 0 }); // 0
54
+ ```
55
+
56
+ ## `round`
57
+
58
+ ```ts
59
+ round(value: number, nDecimalPlaces?: number): number;
60
+ ```
61
+
62
+ Returns the value rounded to the given number of decimal places, or to a whole number where none is given.
63
+
64
+ ```ts
65
+ import { round } from '@williamthorsen/toolbelt.numbers/candidate';
66
+
67
+ round(3.14159, 2); // 3.14
68
+ ```
69
+
70
+ ## `pickInteger`
71
+
72
+ ```ts
73
+ pickInteger(params?: { min?: number; max?: number; seed?: Seed }): number;
74
+ ```
75
+
76
+ Returns a random integer between the bounds, **inclusive** of both, and truncates a non-integer bound. Passing a seed makes the draw deterministic, which is what a test wants.
77
+
78
+ Mind the bound when replacing `Math.floor(Math.random() * n)`: that idiom stops at `n - 1`, so the equivalent is `pickInteger({ max: n - 1 })`.
79
+
80
+ ```ts
81
+ import { pickInteger } from '@williamthorsen/toolbelt.numbers/candidate';
82
+
83
+ pickInteger({ min: 1, max: 6 }); // 1 through 6
84
+ ```
85
+
86
+ ## Adoption checks
87
+
88
+ The package ships a ReadyUp kit, so a project that installs it can ask how far its adoption got:
89
+
90
+ ```sh
91
+ rdy run --packages
92
+ ```
93
+
94
+ The kit reads the project's tracked sources and reports every hand-rolled clamp, decimal rounding, and random integer in them, each counted against the calls the project already makes into this package. All three report at `recommend`: they are correct code that a published utility expresses better, not defects.
95
+
96
+ A random integer used as an array subscript is left alone. That site belongs to `@williamthorsen/toolbelt.arrays`, whose `pickItem` covers it, and reporting it here would mean seeing one line twice under conflicting advice.
97
+
98
+ Bootstrap wrappers under `bin/` are exempt: such a wrapper imports only builtins so its build-first message survives an incomplete install, and importing this package there would replace that message with a module-resolution failure. Tests are exempt too, since they compute these values deliberately.
99
+
100
+ A reviewed site is silenced by an `rdy-ignore` pragma on its own line, or `rdy-ignore-next-line` on the line above. A pragma naming a check's id suppresses that check alone; with no id it covers every check on the line. A failed check prints its id ahead of its fraction, which is the form to write:
101
+
102
+ ```ts
103
+ // rdy-ignore-next-line toolbelt.numbers/no-hand-rolled-clamp -- a reversed range is unreachable here
104
+ const bounded = Math.max(min, Math.min(max, value));
105
+ ```
106
+
107
+ Add the package to `.config/readyup.config.ts` to include it in a routine sweep:
108
+
109
+ ```ts
110
+ export default defineRdyConfig({
111
+ packages: ['@williamthorsen/toolbelt.numbers'],
112
+ });
113
+ ```
@@ -1 +1 @@
1
- export { clamp } from './clamp.js';
1
+ export {};
@@ -1 +1 @@
1
- export { clamp } from "./clamp.js";
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export declare function clamp(value: number, bounds: ClampBounds): number;
2
+ export interface ClampBounds {
3
+ readonly max?: number | undefined;
4
+ readonly min?: number | undefined;
5
+ }
@@ -0,0 +1,8 @@
1
+ export function clamp(value, bounds) {
2
+ const { max = Infinity, min = -Infinity } = bounds;
3
+ if (!(min <= max)) {
4
+ const received = `Received min=${bounds.min}, max=${bounds.max}.`;
5
+ throw new RangeError(`Invalid range: min must be less than or equal to max, and neither can be NaN. ${received}`);
6
+ }
7
+ return Math.max(min, Math.min(max, value));
8
+ }
@@ -1,3 +1,4 @@
1
+ export { clamp, type ClampBounds } from './clamp.js';
1
2
  export { generateRandom } from './generateRandom.js';
2
3
  export { isIntegerString, safeParseInteger } from './integer-string.js';
3
4
  export { isNumericString, safeParseNumber } from './numeric-string.js';
@@ -1,3 +1,4 @@
1
+ export { clamp } from "./clamp.js";
1
2
  export { generateRandom } from "./generateRandom.js";
2
3
  export { isIntegerString, safeParseInteger } from "./integer-string.js";
3
4
  export { isNumericString, safeParseNumber } from "./numeric-string.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/toolbelt.numbers",
3
- "version": "6.0.1",
3
+ "version": "7.0.1",
4
4
  "description": "Utility functions for working with numbers",
5
5
  "keywords": [
6
6
  "esm",
@@ -40,9 +40,17 @@
40
40
  }
41
41
  },
42
42
  "files": [
43
+ ".readyup/kits/*.js",
44
+ ".readyup/manifest.json",
43
45
  "dist/*",
44
46
  "CHANGELOG.md"
45
47
  ],
48
+ "devDependencies": {
49
+ "esbuild": "0.28.2",
50
+ "readyup": "0.32.0",
51
+ "@williamthorsen/toolbelt.adoption": "0.1.0",
52
+ "@williamthorsen/toolbelt.testing": "0.3.0"
53
+ },
46
54
  "engines": {
47
55
  "node": ">=24.0.0"
48
56
  },
@@ -1,6 +0,0 @@
1
- interface ClampParams {
2
- min?: number;
3
- max?: number;
4
- }
5
- export declare function clamp(value: number, { min, max }: ClampParams): number;
6
- export {};
@@ -1,8 +0,0 @@
1
- export function clamp(value, { min, max }) {
2
- if (min !== undefined && max !== undefined && min > max) {
3
- throw new RangeError('Minimum value cannot be greater than maximum value');
4
- }
5
- const minValue = min ?? -Infinity;
6
- const maxValue = max ?? Infinity;
7
- return Math.max(minValue, Math.min(maxValue, value));
8
- }