@williamthorsen/toolbelt.numbers 6.0.0 → 7.0.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.
@@ -0,0 +1,471 @@
1
+ /** @noformat — @generated. Do not edit. Compiled by rdy. */
2
+ /* eslint-disable */
3
+ export const __readyupVersion = "0.30.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
+ discoverWorkspaces,
52
+ readTrackedSources
53
+ } from "readyup/check-utils";
54
+ var NOT_A_REPO = "the project is not a git working tree, and these checks read the files git tracks";
55
+ var SELF = "this project publishes the package these checks are for";
56
+ function defineAdoptionKit(spec) {
57
+ const cache = {};
58
+ return defineRdyKit({
59
+ description: spec.description,
60
+ defaultSeverity: "warn",
61
+ checklists: [
62
+ {
63
+ name: "adoption",
64
+ checks: spec.checks.map((check) => ({
65
+ name: check.name,
66
+ ...check.severity !== void 0 && { severity: check.severity },
67
+ skip: skipUnlessProjectIsAccountable,
68
+ check: () => reportKinds(check.kinds),
69
+ fix: check.fix
70
+ }))
71
+ }
72
+ ]
73
+ });
74
+ function loadSummary() {
75
+ cache.summary ??= readProject();
76
+ return cache.summary;
77
+ }
78
+ async function readProject() {
79
+ const sources = await readTrackedSources(spec.pathFilter);
80
+ if (sources === void 0) return void 0;
81
+ return {
82
+ adoptedCount: countPackageUsage(sources, {
83
+ exportNames: spec.exportNames,
84
+ packageName: spec.packageName
85
+ }),
86
+ findings: sources.flatMap((source) => spec.detect(source.text).map((site) => ({ ...site, path: source.path }))),
87
+ sourceCount: sources.length
88
+ };
89
+ }
90
+ async function reportKinds(kinds) {
91
+ const summary = await loadSummary();
92
+ if (summary === void 0) return { ok: true };
93
+ return buildFindingReport({
94
+ adoptedCount: summary.adoptedCount,
95
+ findings: summary.findings,
96
+ shouldReport: (finding) => kinds.includes(finding.kind)
97
+ });
98
+ }
99
+ async function skipUnlessProjectIsAccountable() {
100
+ if (discoverWorkspaces().some((workspace) => workspace.name === spec.packageName)) return SELF;
101
+ const summary = await loadSummary();
102
+ if (summary === void 0) return NOT_A_REPO;
103
+ return summary.sourceCount === 0 ? spec.noSourcesReason : false;
104
+ }
105
+ }
106
+
107
+ // ../adoption/src/portable/blankNonCode.ts
108
+ var REGEX_PRECEDERS = /* @__PURE__ */ new Set([
109
+ "!",
110
+ "%",
111
+ "&",
112
+ "(",
113
+ "*",
114
+ "+",
115
+ ",",
116
+ "-",
117
+ ":",
118
+ ";",
119
+ "=",
120
+ ">",
121
+ "?",
122
+ "[",
123
+ "^",
124
+ "{",
125
+ "|",
126
+ "~"
127
+ ]);
128
+ var EXPRESSION_KEYWORDS2 = /* @__PURE__ */ new Set([
129
+ "await",
130
+ "case",
131
+ "delete",
132
+ "do",
133
+ "else",
134
+ "in",
135
+ "instanceof",
136
+ "new",
137
+ "of",
138
+ "return",
139
+ "typeof",
140
+ "void",
141
+ "yield"
142
+ ]);
143
+ var WORD_CHAR = /[\w$]/;
144
+ function blankNonCode(source) {
145
+ const scan = { out: source.split(""), source };
146
+ const start = source.startsWith("#!") ? blankSpan(scan, 0, findLineEnd(source, 0)) : 0;
147
+ scanCode(scan, start, false);
148
+ return scan.out.join("");
149
+ }
150
+ function blankQuoted(scan, start, quote) {
151
+ const { source } = scan;
152
+ let index = start + 1;
153
+ while (index < source.length) {
154
+ const char = source[index];
155
+ if (char === "\\") {
156
+ index += 2;
157
+ continue;
158
+ }
159
+ if (char === "\n") break;
160
+ if (char === quote) return blankSpan(scan, start + 1, index) + 1;
161
+ index += 1;
162
+ }
163
+ return start + 1;
164
+ }
165
+ function blankSpan(scan, from, to) {
166
+ for (let index = from; index < to; index += 1) {
167
+ const char = scan.source[index];
168
+ if (char !== "\n" && char !== "\r") scan.out[index] = " ";
169
+ }
170
+ return to;
171
+ }
172
+ function blankTemplate(scan, start) {
173
+ const { source } = scan;
174
+ let index = start + 1;
175
+ let textStart = index;
176
+ while (index < source.length) {
177
+ const char = source[index];
178
+ if (char === "\\") {
179
+ index += 2;
180
+ continue;
181
+ }
182
+ if (char === "`") return blankSpan(scan, textStart, index) + 1;
183
+ if (char === "$" && source[index + 1] === "{") {
184
+ blankSpan(scan, textStart, index);
185
+ const close = scanCode(scan, index + 2, true);
186
+ index = close < source.length ? close + 1 : close;
187
+ textStart = index;
188
+ continue;
189
+ }
190
+ index += 1;
191
+ }
192
+ return blankSpan(scan, textStart, source.length);
193
+ }
194
+ function findBlockCommentEnd(source, from) {
195
+ const end = source.indexOf("*/", from + 2);
196
+ return end === -1 ? source.length : end + 2;
197
+ }
198
+ function findLineEnd(source, from) {
199
+ const end = source.indexOf("\n", from);
200
+ return end === -1 ? source.length : end;
201
+ }
202
+ function findRegexEnd(source, start) {
203
+ let index = start + 1;
204
+ let isInClass = false;
205
+ while (index < source.length) {
206
+ const char = source[index];
207
+ if (char === "\\") {
208
+ index += 2;
209
+ continue;
210
+ }
211
+ if (char === "\n") return void 0;
212
+ if (char === "[") isInClass = true;
213
+ else if (char === "]") isInClass = false;
214
+ else if (char === "/" && !isInClass) return index + 1;
215
+ index += 1;
216
+ }
217
+ return void 0;
218
+ }
219
+ function findWordEnd(source, from) {
220
+ let index = from;
221
+ while (index < source.length && WORD_CHAR.test(source[index] ?? "")) index += 1;
222
+ return index;
223
+ }
224
+ function scanCode(scan, from, isInterpolation) {
225
+ const { source } = scan;
226
+ let previousToken = "";
227
+ let braceDepth = 0;
228
+ let index = from;
229
+ while (index < source.length) {
230
+ const char = source[index] ?? "";
231
+ const next = source[index + 1];
232
+ if (char === "/" && next === "/") {
233
+ index = blankSpan(scan, index, findLineEnd(source, index));
234
+ continue;
235
+ }
236
+ if (char === "/" && next === "*") {
237
+ index = blankSpan(scan, index, findBlockCommentEnd(source, index));
238
+ continue;
239
+ }
240
+ if (char === "'" || char === '"') {
241
+ index = blankQuoted(scan, index, char);
242
+ previousToken = char;
243
+ continue;
244
+ }
245
+ if (char === "`") {
246
+ index = blankTemplate(scan, index);
247
+ previousToken = char;
248
+ continue;
249
+ }
250
+ if (char === "/" && startsRegex(previousToken)) {
251
+ const end = findRegexEnd(source, index);
252
+ if (end !== void 0) {
253
+ blankSpan(scan, index + 1, end - 1);
254
+ index = end;
255
+ previousToken = "/";
256
+ continue;
257
+ }
258
+ }
259
+ if (isInterpolation && char === "{") braceDepth += 1;
260
+ else if (isInterpolation && char === "}") {
261
+ if (braceDepth === 0) return index;
262
+ braceDepth -= 1;
263
+ }
264
+ if (WORD_CHAR.test(char)) {
265
+ const end = findWordEnd(source, index);
266
+ previousToken = source.slice(index, end);
267
+ index = end;
268
+ continue;
269
+ }
270
+ if (!/\s/.test(char)) previousToken = char;
271
+ index += 1;
272
+ }
273
+ return index;
274
+ }
275
+ function startsRegex(previousToken) {
276
+ if (previousToken === "") return true;
277
+ if (previousToken.length === 1) return REGEX_PRECEDERS.has(previousToken);
278
+ return EXPRESSION_KEYWORDS2.has(previousToken);
279
+ }
280
+
281
+ // ../adoption/src/portable/condenseWhitespace.ts
282
+ function condenseWhitespace(text) {
283
+ return text.replaceAll(/\s+/g, " ");
284
+ }
285
+
286
+ // ../adoption/src/portable/getLineAtOffset.ts
287
+ function getLineAtOffset(source, offset) {
288
+ let line = 1;
289
+ for (let index = 0; index < offset; index += 1) {
290
+ if (source[index] === "\n") line += 1;
291
+ }
292
+ return line;
293
+ }
294
+
295
+ // ../adoption/src/portable/readBalancedGroup.ts
296
+ var PARENTHESES = { close: ")", open: "(" };
297
+ function readBalancedGroup(source, from, delimiters) {
298
+ const start = source.indexOf(delimiters.open, from);
299
+ if (start === -1) return void 0;
300
+ let depth = 0;
301
+ for (let index = start; index < source.length; index += 1) {
302
+ if (source[index] === delimiters.open) depth += 1;
303
+ else if (source[index] === delimiters.close) {
304
+ depth -= 1;
305
+ if (depth === 0) return { end: index + 1, start };
306
+ }
307
+ }
308
+ return void 0;
309
+ }
310
+
311
+ // ../adoption/src/portable/readAnchoredWindow.ts
312
+ function readAnchoredWindow(source, offset, lengths) {
313
+ return {
314
+ after: condenseWhitespace(source.slice(offset, offset + lengths.lookahead)),
315
+ before: condenseWhitespace(source.slice(Math.max(0, offset - lengths.lookbehind), offset))
316
+ };
317
+ }
318
+
319
+ // src/readiness/adoptedExports.ts
320
+ var ADOPTED_EXPORTS = [
321
+ "clamp",
322
+ "generateRandom",
323
+ "IntSeededRng",
324
+ "isIntegerString",
325
+ "isNumericString",
326
+ "makeRng",
327
+ "pickInteger",
328
+ "round",
329
+ "safeParseInteger",
330
+ "safeParseNumber",
331
+ "scale",
332
+ "SeededRng"
333
+ ];
334
+
335
+ // src/readiness/listClampNestLines.ts
336
+ var BOUNDING_CALL = /\bMath\.(?<name>max|min)\s*\(/g;
337
+ var CLOSERS = ")]}";
338
+ var OPENERS = "([{";
339
+ var NESTED_HEAD = { max: /^Math\.max\s*\(/, min: /^Math\.min\s*\(/ };
340
+ var OPPOSITE = { max: "min", min: "max" };
341
+ function listClampNestLines(source) {
342
+ const lines = [];
343
+ let claimedUntil = 0;
344
+ for (const match of source.matchAll(BOUNDING_CALL)) {
345
+ if (match.index < claimedUntil) continue;
346
+ const name = readBoundingName(match.groups?.["name"]);
347
+ if (name === void 0) continue;
348
+ const group = readBalancedGroup(source, match.index, PARENTHESES);
349
+ if (group === void 0) continue;
350
+ const args = listTopLevelArguments(source.slice(group.start + 1, group.end - 1));
351
+ if (args.length !== 2 || args.every((argument) => !isBoundingCall(argument, OPPOSITE[name]))) continue;
352
+ claimedUntil = group.end;
353
+ lines.push(getLineAtOffset(source, match.index));
354
+ }
355
+ return lines;
356
+ }
357
+ function isBoundingCall(argument, name) {
358
+ const text = argument.trim();
359
+ if (!NESTED_HEAD[name].test(text)) return false;
360
+ const group = readBalancedGroup(text, 0, PARENTHESES);
361
+ if (group === void 0 || group.end !== text.length) return false;
362
+ return listTopLevelArguments(text.slice(group.start + 1, group.end - 1)).length === 2;
363
+ }
364
+ function listTopLevelArguments(inner) {
365
+ const args = [];
366
+ let depth = 0;
367
+ let start = 0;
368
+ for (let index = 0; index < inner.length; index += 1) {
369
+ const char = inner[index];
370
+ if (char === void 0) continue;
371
+ if (OPENERS.includes(char)) depth += 1;
372
+ else if (CLOSERS.includes(char)) depth -= 1;
373
+ else if (char === "," && depth === 0) {
374
+ args.push(inner.slice(start, index));
375
+ start = index + 1;
376
+ }
377
+ }
378
+ args.push(inner.slice(start));
379
+ const last = args.at(-1);
380
+ if (args.length > 1 && last !== void 0 && last.trim() === "") args.pop();
381
+ return args;
382
+ }
383
+ function readBoundingName(name) {
384
+ return name === "max" || name === "min" ? name : void 0;
385
+ }
386
+
387
+ // src/readiness/listRandomIntegerLines.ts
388
+ var FLOORED_RANDOM = /\bMath\.floor\s*\(\s*Math\.random\s*\(\s*\)\s*\*/g;
389
+ var WINDOW = { lookahead: 0, lookbehind: 80 };
390
+ function listRandomIntegerLines(source) {
391
+ const lines = [];
392
+ for (const match of source.matchAll(FLOORED_RANDOM)) {
393
+ const { before } = readAnchoredWindow(source, match.index, WINDOW);
394
+ if (isArraySubscript(before)) continue;
395
+ lines.push(getLineAtOffset(source, match.index));
396
+ }
397
+ return lines;
398
+ }
399
+
400
+ // src/readiness/listRoundScaleLines.ts
401
+ var POWER_OF_TEN = String.raw`10\s*\*\*\s*[\w$]+|10+`;
402
+ var ROUND_CALL = /\bMath\.round\s*\(/g;
403
+ var TRAILING_FACTOR = new RegExp(String.raw`\*\s*(?<factor>${POWER_OF_TEN})\s*,?\s*$`);
404
+ var LEADING_DIVISOR = new RegExp(String.raw`^\s*/\s*(?<divisor>${POWER_OF_TEN})(?![\w$.])`);
405
+ function listRoundScaleLines(source) {
406
+ const lines = [];
407
+ for (const match of source.matchAll(ROUND_CALL)) {
408
+ const group = readBalancedGroup(source, match.index, PARENTHESES);
409
+ if (group === void 0) continue;
410
+ const factor = TRAILING_FACTOR.exec(source.slice(group.start + 1, group.end - 1))?.groups?.["factor"];
411
+ if (factor === void 0) continue;
412
+ const divisor = LEADING_DIVISOR.exec(source.slice(group.end))?.groups?.["divisor"];
413
+ if (divisor === void 0 || !isSameFactor(factor, divisor)) continue;
414
+ lines.push(getLineAtOffset(source, match.index));
415
+ }
416
+ return lines;
417
+ }
418
+ function isSameFactor(factor, divisor) {
419
+ return factor.replaceAll(/\s+/g, "") === divisor.replaceAll(/\s+/g, "");
420
+ }
421
+
422
+ // src/readiness/listMathIdioms.ts
423
+ function listMathIdioms(source) {
424
+ const code = blankNonCode(source);
425
+ const sites = [
426
+ ...toSites("clamp-nest", listClampNestLines(code)),
427
+ ...toSites("random-integer", listRandomIntegerLines(code)),
428
+ ...toSites("round-scale", listRoundScaleLines(code))
429
+ ];
430
+ return sites.toSorted((a, b) => a.line - b.line);
431
+ }
432
+ function toSites(kind, lines) {
433
+ return lines.map((line) => ({ kind, line }));
434
+ }
435
+
436
+ // .readyup/kits/default.ts
437
+ var PACKAGE_NAME = "@williamthorsen/toolbelt.numbers";
438
+ var README_URL = "https://github.com/williamthorsen/toolbelt/tree/main/packages/numbers#readme";
439
+ var default_default = defineAdoptionKit({
440
+ description: `Adoption checks for a project consuming ${PACKAGE_NAME}`,
441
+ detect: listMathIdioms,
442
+ exportNames: ADOPTED_EXPORTS,
443
+ noSourcesReason: "the project holds no JavaScript or TypeScript sources outside the exempt paths",
444
+ packageName: PACKAGE_NAME,
445
+ // A test computes these values deliberately, and a bootstrap wrapper's hand-rolled arithmetic is what keeps
446
+ // its build-first message alive through an incomplete install.
447
+ pathFilter: isAdoptableSource,
448
+ checks: [
449
+ {
450
+ name: "No source clamps a value by hand",
451
+ kinds: ["clamp-nest"],
452
+ severity: "recommend",
453
+ 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}`
454
+ },
455
+ {
456
+ name: "No source rounds to decimal places by hand",
457
+ kinds: ["round-scale"],
458
+ severity: "recommend",
459
+ 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}`
460
+ },
461
+ {
462
+ name: "No source derives a random integer by hand",
463
+ kinds: ["random-integer"],
464
+ severity: "recommend",
465
+ 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}`
466
+ }
467
+ ]
468
+ });
469
+ export {
470
+ default_default as default
471
+ };
@@ -0,0 +1,100 @@
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": "737a39d2",
23
+ "kind": "module",
24
+ "path": "../../adoption/src/kits/defineAdoptionKit.ts"
25
+ },
26
+ {
27
+ "hash": "29115f53",
28
+ "kind": "module",
29
+ "path": "../../adoption/src/mod.ts"
30
+ },
31
+ {
32
+ "hash": "6617674b",
33
+ "kind": "module",
34
+ "path": "../../adoption/src/portable/blankNonCode.ts"
35
+ },
36
+ {
37
+ "hash": "e8d0444d",
38
+ "kind": "module",
39
+ "path": "../../adoption/src/portable/condenseWhitespace.ts"
40
+ },
41
+ {
42
+ "hash": "4da99027",
43
+ "kind": "module",
44
+ "path": "../../adoption/src/portable/getLineAtOffset.ts"
45
+ },
46
+ {
47
+ "hash": "b6d99ce7",
48
+ "kind": "module",
49
+ "path": "../../adoption/src/portable/listFunctionBodies.ts"
50
+ },
51
+ {
52
+ "hash": "4d2d8e11",
53
+ "kind": "module",
54
+ "path": "../../adoption/src/portable/readAnchoredWindow.ts"
55
+ },
56
+ {
57
+ "hash": "498e3ca4",
58
+ "kind": "module",
59
+ "path": "../../adoption/src/portable/readBalancedGroup.ts"
60
+ },
61
+ {
62
+ "hash": "824d5042",
63
+ "kind": "module",
64
+ "path": "kits/default.ts"
65
+ },
66
+ {
67
+ "hash": "f3d9105b",
68
+ "kind": "module",
69
+ "path": "../src/readiness/adoptedExports.ts"
70
+ },
71
+ {
72
+ "hash": "54ee66e5",
73
+ "kind": "module",
74
+ "path": "../src/readiness/listClampNestLines.ts"
75
+ },
76
+ {
77
+ "hash": "47d35285",
78
+ "kind": "module",
79
+ "path": "../src/readiness/listMathIdioms.ts"
80
+ },
81
+ {
82
+ "hash": "fa0a23b7",
83
+ "kind": "module",
84
+ "path": "../src/readiness/listRandomIntegerLines.ts"
85
+ },
86
+ {
87
+ "hash": "ae0ac903",
88
+ "kind": "module",
89
+ "path": "../src/readiness/listRoundScaleLines.ts"
90
+ }
91
+ ],
92
+ "name": "default",
93
+ "path": "kits/default.js",
94
+ "readyupVersion": "0.30.0",
95
+ "source": "kits/default.ts",
96
+ "sourceHash": "824d5042",
97
+ "targetHash": "8fa7ed41"
98
+ }
99
+ ]
100
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 7.0.0 — 2026-08-21
6
+
7
+ ### Features
8
+
9
+ - 🚨 **Breaking:** Promote clamp to the candidate tier with a stricter bounds contract (#185)
10
+
11
+ 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`.
12
+
13
+ Migration: `clamp` is no longer exported from the `/draft` subpath; consumers import it from `/candidate`.
14
+
15
+ - Add a ReadyUp adoption kit to `numbers` (#188)
16
+
17
+ 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.
18
+
19
+ ### Bug fixes
20
+
21
+ - Blank comments and literals before a detector reads a source (#191)
22
+
23
+ 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.
24
+
25
+ ### Refactoring
26
+
27
+ - Break the workspace dependency cycle by making `adoption` a leaf (#195)
28
+
29
+ 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.
30
+
31
+ ### Dependencies
32
+
33
+ - Upgrade all deps to latest version
34
+
35
+ ## 6.0.1 — 2026-08-13
36
+
37
+ ### Tooling
38
+
39
+ - Remove redundant .gitignore files
40
+ - Populate manifest metadata and adopt a pnpm catalog (#140)
41
+
42
+ Adopts a pnpm catalog to avoid specifying the version of a common dependency in multiple places. Separately, fixes violations of newly activated `package-json` lint rules. Missing values have been added to `package.json` fields across the repo, and package descriptions are improved.
43
+
5
44
  ## 6.0.0 — 2026-08-12
6
45
 
7
46
  ### Features
package/README.md CHANGED
@@ -3,15 +3,100 @@
3
3
  Utility functions for working with numbers.
4
4
 
5
5
  <!-- section:release-notes -->
6
- ## Release notes — v6.0.0 (2026-08-12)
6
+ ## Release notes — v7.0.0 (2026-08-21)
7
7
 
8
8
  ### Features
9
9
 
10
- - 🚨 **Breaking:** Rename get* functions by return kind and verb specificity (#119)
10
+ - 🚨 **Breaking:** Promote clamp to the candidate tier with a stricter bounds contract (#185)
11
11
 
12
- Renames thirteen functions across various packages to align with a consistent naming pattern.
12
+ 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`.
13
+
14
+ Migration: `clamp` is no longer exported from the `/draft` subpath; consumers import it from `/candidate`.
15
+
16
+ - Add a ReadyUp adoption kit to `numbers` (#188)
17
+
18
+ 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.
19
+
20
+ ### Bug fixes
21
+
22
+ - Blank comments and literals before a detector reads a source (#191)
23
+
24
+ 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.
13
25
  <!-- /section:release-notes -->
14
26
 
15
27
  ## Installation
16
28
 
29
+ ```sh
30
+ pnpm add @williamthorsen/toolbelt.numbers
31
+ ```
32
+
17
33
  Requires Node.js 24 or later.
34
+
35
+ `clamp`, `round`, and `pickInteger` are candidate tier: imported from `@williamthorsen/toolbelt.numbers/candidate` rather than the package root, and subject to change.
36
+
37
+ ## `clamp`
38
+
39
+ ```ts
40
+ clamp(value: number, bounds: { min?: number; max?: number }): number;
41
+ ```
42
+
43
+ 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.
44
+
45
+ ```ts
46
+ import { clamp } from '@williamthorsen/toolbelt.numbers/candidate';
47
+
48
+ clamp(15, { min: 0, max: 10 }); // 10
49
+ clamp(-5, { min: 0 }); // 0
50
+ ```
51
+
52
+ ## `round`
53
+
54
+ ```ts
55
+ round(value: number, nDecimalPlaces?: number): number;
56
+ ```
57
+
58
+ Returns the value rounded to the given number of decimal places, or to a whole number where none is given.
59
+
60
+ ```ts
61
+ import { round } from '@williamthorsen/toolbelt.numbers/candidate';
62
+
63
+ round(3.14159, 2); // 3.14
64
+ ```
65
+
66
+ ## `pickInteger`
67
+
68
+ ```ts
69
+ pickInteger(params?: { min?: number; max?: number; seed?: Seed }): number;
70
+ ```
71
+
72
+ 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.
73
+
74
+ Mind the bound when replacing `Math.floor(Math.random() * n)`: that idiom stops at `n - 1`, so the equivalent is `pickInteger({ max: n - 1 })`.
75
+
76
+ ```ts
77
+ import { pickInteger } from '@williamthorsen/toolbelt.numbers/candidate';
78
+
79
+ pickInteger({ min: 1, max: 6 }); // 1 through 6
80
+ ```
81
+
82
+ ## Adoption checks
83
+
84
+ The package ships a ReadyUp kit, so a project that installs it can ask how far its adoption got:
85
+
86
+ ```sh
87
+ rdy run --packages
88
+ ```
89
+
90
+ 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.
91
+
92
+ 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.
93
+
94
+ 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.
95
+
96
+ Add the package to `.config/readyup.config.ts` to include it in a routine sweep:
97
+
98
+ ```ts
99
+ export default defineRdyConfig({
100
+ packages: ['@williamthorsen/toolbelt.numbers'],
101
+ });
102
+ ```
@@ -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,8 +1,17 @@
1
1
  {
2
2
  "name": "@williamthorsen/toolbelt.numbers",
3
- "version": "6.0.0",
4
- "description": "API",
5
- "keywords": [],
3
+ "version": "7.0.0",
4
+ "description": "Utility functions for working with numbers",
5
+ "keywords": [
6
+ "esm",
7
+ "math",
8
+ "number",
9
+ "random",
10
+ "seeded-random",
11
+ "toolbelt",
12
+ "typescript",
13
+ "utilities"
14
+ ],
6
15
  "homepage": "https://github.com/williamthorsen/toolbelt/tree/main/packages/numbers#readme",
7
16
  "bugs": {
8
17
  "url": "https://github.com/williamthorsen/toolbelt/issues"
@@ -14,6 +23,7 @@
14
23
  },
15
24
  "license": "ISC",
16
25
  "author": "William Thorsen <william@thorsen.dev> (https://github.com/williamthorsen)",
26
+ "sideEffects": false,
17
27
  "type": "module",
18
28
  "exports": {
19
29
  ".": {
@@ -30,9 +40,17 @@
30
40
  }
31
41
  },
32
42
  "files": [
43
+ ".readyup/kits/*.js",
44
+ ".readyup/manifest.json",
33
45
  "dist/*",
34
46
  "CHANGELOG.md"
35
47
  ],
48
+ "devDependencies": {
49
+ "esbuild": "0.28.2",
50
+ "readyup": "0.30.0",
51
+ "@williamthorsen/toolbelt.adoption": "0.1.0",
52
+ "@williamthorsen/toolbelt.testing": "0.3.0"
53
+ },
36
54
  "engines": {
37
55
  "node": ">=24.0.0"
38
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
- }