itworksbut 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "itworksbut",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Static CI checks for common security, repo, dependency, build, and deployment risks in JavaScript vibe coding projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,6 +7,16 @@ import { isServerOrApiFile } from "../checks/helpers.js";
7
7
  const HTTP_METHODS = ["get", "head", "post", "put", "patch", "delete", "options"];
8
8
  const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
9
9
  const ROUTE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
10
+ const API_HANDLER_EVIDENCE = [
11
+ /\b(?:req|request)\s*,\s*(?:res|response)\b/i,
12
+ /\b(?:res|response)\s*\.\s*(?:json|send|status|end|redirect|writeHead|setHeader)\s*\(/i,
13
+ /\b(?:ctx|context)\s*\.\s*(?:body|response|status|json)\b/i,
14
+ /\bNextResponse\s*\.\s*(?:json|redirect)\s*\(/,
15
+ /\b(?:new\s+Response|Response\.json)\s*\(/,
16
+ /\bdefineEventHandler\s*\(/,
17
+ /\bexport\s+(?:async\s+)?function\s+(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\b/,
18
+ /\bexport\s+(?:const|let|var)\s+(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s*=/
19
+ ];
10
20
  const DISCOVERY_IGNORE = [
11
21
  ...DEFAULT_IGNORE,
12
22
  "test/**",
@@ -40,7 +50,7 @@ export async function discoverEndpointsFromFiles({ rootPath, files, readFile })
40
50
  const mountPrefixes = collectMountPrefixes(fileContents);
41
51
 
42
52
  for (const { file, content } of fileContents) {
43
- endpoints.push(...discoverExpressEndpoints(file, content));
53
+ endpoints.push(...discoverExpressEndpoints(file, content, mountPrefixes));
44
54
  endpoints.push(...discoverMountedRouterEndpoints(file, content, mountPrefixes));
45
55
  endpoints.push(...discoverFastifyRouteObjects(file, content));
46
56
  endpoints.push(...discoverFetchReferences(file, content));
@@ -89,34 +99,41 @@ export function classifyEndpoints(endpoints) {
89
99
  };
90
100
  }
91
101
 
92
- function discoverExpressEndpoints(file, content) {
102
+ function discoverExpressEndpoints(file, content, mountPrefixes = []) {
93
103
  const endpoints = [];
94
104
  const methods = HTTP_METHODS.join("|");
95
- const regex = new RegExp(`\\b[A-Za-z_$][\\w$]*\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
105
+ const regex = new RegExp(`\\b([A-Za-z_$][\\w$]*)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\3`, "gi");
106
+ const receivers = collectRouteReceivers(content);
107
+ const mountedRouterFile = hasApplicableMount(file, mountPrefixes);
96
108
  let match;
97
109
 
98
110
  while ((match = regex.exec(content)) !== null) {
99
- const routePath = normalizeRoutePath(match[3]);
111
+ if (!isRouteReceiver(match[1], receivers)) continue;
112
+ if (mountedRouterFile && isRouterReceiver(match[1], receivers)) continue;
113
+ const routePath = normalizeRoutePath(match[4]);
100
114
  if (!routePath) continue;
101
- endpoints.push(endpoint(match[1], routePath, file, "express"));
115
+ endpoints.push(endpoint(match[2], routePath, file, "express"));
102
116
  }
103
117
 
104
118
  return endpoints;
105
119
  }
106
120
 
107
121
  function discoverMountedRouterEndpoints(file, content, mountPrefixes) {
108
- if (mountPrefixes.length === 0) return [];
122
+ const applicableMounts = mountPrefixes.filter((mount) => mountAppliesToFile(mount, file));
123
+ if (applicableMounts.length === 0) return [];
109
124
 
110
125
  const endpoints = [];
111
126
  const methods = HTTP_METHODS.join("|");
112
- const regex = new RegExp(`\\b(?:router|Router|apiRouter|routes|route)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
127
+ const regex = new RegExp(`\\b([A-Za-z_$][\\w$]*)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\3`, "gi");
128
+ const receivers = collectRouteReceivers(content);
113
129
  let match;
114
130
 
115
131
  while ((match = regex.exec(content)) !== null) {
116
- const routePath = normalizeRoutePath(match[3]);
132
+ if (!isRouterReceiver(match[1], receivers)) continue;
133
+ const routePath = normalizeRoutePath(match[4]);
117
134
  if (!routePath || routePath.startsWith("/api")) continue;
118
- for (const prefix of mountPrefixes) {
119
- endpoints.push(endpoint(match[1], joinRoutePath(prefix, routePath), file, "mounted-router"));
135
+ for (const mount of applicableMounts) {
136
+ endpoints.push(endpoint(match[2], joinRoutePath(mount.prefix, routePath), file, "mounted-router"));
120
137
  }
121
138
  }
122
139
 
@@ -240,11 +257,16 @@ function discoverApiCandidateFileEndpoints(file, content) {
240
257
 
241
258
  function discoverExportedRouteMethods(content) {
242
259
  const methods = new Set();
243
- const regex = /\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\b/g;
244
- let match;
260
+ const regexes = [
261
+ /\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\b/g,
262
+ /\bexport\s+(?:const|let|var)\s+(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s*=/g
263
+ ];
245
264
 
246
- while ((match = regex.exec(content)) !== null) {
247
- methods.add(match[1]);
265
+ for (const regex of regexes) {
266
+ let match;
267
+ while ((match = regex.exec(content)) !== null) {
268
+ methods.add(match[1]);
269
+ }
248
270
  }
249
271
 
250
272
  return [...methods];
@@ -263,22 +285,128 @@ function discoverMethodGuards(content) {
263
285
  }
264
286
 
265
287
  function collectMountPrefixes(fileContents) {
266
- const prefixes = new Set();
267
- const useRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*use\s*\(\s*(['"`])([^'"`]+)\1\s*,/gi;
268
- const registerRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*register\s*\([^)]*\{[^}]*\bprefix\s*:\s*(['"`])([^'"`]+)\1/gi;
269
-
270
- for (const { content } of fileContents) {
271
- for (const regex of [useRegex, registerRegex]) {
272
- regex.lastIndex = 0;
273
- let match;
274
- while ((match = regex.exec(content)) !== null) {
275
- const prefix = normalizeRoutePath(match[2]);
276
- if (prefix && prefix !== "/") prefixes.add(prefix);
277
- }
288
+ const mounts = [];
289
+ const useRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*use\s*\(\s*(['"`])([^'"`]+)\1\s*,\s*(?:([A-Za-z_$][\w$]*)\s*\(|([A-Za-z_$][\w$]*)\b|require\s*\(\s*(['"`])([^'"`]+)\5\s*\))/gi;
290
+ const registerRegex = /\b[A-Za-z_$][\w$]*\s*\.\s*register\s*\(\s*([A-Za-z_$][\w$]*)\b[\s\S]*?\bprefix\s*:\s*(['"`])([^'"`]+)\2/gi;
291
+
292
+ for (const { file, content } of fileContents) {
293
+ const localModuleBindings = collectLocalModuleBindings(file, content);
294
+ let match;
295
+
296
+ useRegex.lastIndex = 0;
297
+ while ((match = useRegex.exec(content)) !== null) {
298
+ const mount = createUseMount(match, localModuleBindings, file);
299
+ if (mount) mounts.push(mount);
300
+ }
301
+
302
+ registerRegex.lastIndex = 0;
303
+ while ((match = registerRegex.exec(content)) !== null) {
304
+ const mount = createNamedMount(match[3], match[1], localModuleBindings);
305
+ if (mount) mounts.push(mount);
278
306
  }
279
307
  }
280
308
 
281
- return [...prefixes].sort();
309
+ return dedupeMounts(mounts);
310
+ }
311
+
312
+ function collectLocalModuleBindings(file, content) {
313
+ const bindings = new Map();
314
+ const destructuredRequireRegex = /\b(?:const|let|var)\s*\{\s*([^}]+)\s*\}\s*=\s*require\s*\(\s*(['"`])([^'"`]+)\2\s*\)/g;
315
+ const requireRegex = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*(['"`])([^'"`]+)\2\s*\)/g;
316
+ const namedImportRegex = /\bimport\s*\{\s*([^}]+)\s*\}\s*from\s*(['"`])([^'"`]+)\2/g;
317
+ const defaultImportRegex = /\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*(['"`])([^'"`]+)\2/g;
318
+ const namespaceImportRegex = /\bimport\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*(['"`])([^'"`]+)\2/g;
319
+
320
+ let match;
321
+ while ((match = destructuredRequireRegex.exec(content)) !== null) {
322
+ addNamedBindings(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
323
+ }
324
+
325
+ while ((match = requireRegex.exec(content)) !== null) {
326
+ addBinding(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
327
+ }
328
+
329
+ while ((match = namedImportRegex.exec(content)) !== null) {
330
+ addNamedBindings(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
331
+ }
332
+
333
+ while ((match = defaultImportRegex.exec(content)) !== null) {
334
+ addBinding(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
335
+ }
336
+
337
+ while ((match = namespaceImportRegex.exec(content)) !== null) {
338
+ addBinding(bindings, match[1], resolveLocalModuleFiles(file, match[3]));
339
+ }
340
+
341
+ return bindings;
342
+ }
343
+
344
+ function addNamedBindings(bindings, bindingList, targetFiles) {
345
+ for (const binding of bindingList.split(",")) {
346
+ const normalized = binding.trim();
347
+ if (!normalized) continue;
348
+ const localName = normalized.includes(" as ")
349
+ ? normalized.split(/\s+as\s+/).pop().trim()
350
+ : normalized.split(":").pop().trim();
351
+ addBinding(bindings, localName, targetFiles);
352
+ }
353
+ }
354
+
355
+ function addBinding(bindings, name, targetFiles) {
356
+ if (!name || targetFiles.length === 0) return;
357
+ bindings.set(name, targetFiles);
358
+ }
359
+
360
+ function createUseMount(match, localModuleBindings, file) {
361
+ const [, , rawPrefix, calledIdentifier, identifier, , requireSpecifier] = match;
362
+ const mountedName = calledIdentifier || identifier;
363
+ if (requireSpecifier) {
364
+ return createMount(rawPrefix, resolveLocalModuleFiles(file, requireSpecifier));
365
+ }
366
+ return createNamedMount(rawPrefix, mountedName, localModuleBindings, Boolean(calledIdentifier));
367
+ }
368
+
369
+ function createNamedMount(rawPrefix, mountedName, localModuleBindings, called = false) {
370
+ const prefix = normalizeRoutePath(rawPrefix);
371
+ if (!prefix || prefix === "/") return null;
372
+
373
+ const targetFiles = localModuleBindings.get(mountedName) || [];
374
+ if (targetFiles.length > 0) return { prefix, targetFiles };
375
+ if (!called && isRouterReceiver(mountedName)) return { prefix, targetFiles: [] };
376
+
377
+ return null;
378
+ }
379
+
380
+ function createMount(rawPrefix, targetFiles) {
381
+ const prefix = normalizeRoutePath(rawPrefix);
382
+ if (!prefix || prefix === "/" || targetFiles.length === 0) return null;
383
+ return { prefix, targetFiles };
384
+ }
385
+
386
+ function resolveLocalModuleFiles(file, specifier) {
387
+ if (!specifier || !specifier.startsWith(".")) return [];
388
+
389
+ const dirname = path.posix.dirname(normalizeFilePath(file));
390
+ const base = path.posix.normalize(path.posix.join(dirname, normalizeFilePath(specifier)));
391
+ if (ROUTE_EXTENSIONS.has(path.posix.extname(base))) return [base];
392
+
393
+ return [
394
+ ...[...ROUTE_EXTENSIONS].map((extension) => `${base}${extension}`),
395
+ ...[...ROUTE_EXTENSIONS].map((extension) => `${base}/index${extension}`)
396
+ ];
397
+ }
398
+
399
+ function dedupeMounts(mounts) {
400
+ const byKey = new Map();
401
+ for (const mount of mounts) {
402
+ const key = `${mount.prefix}|${mount.targetFiles.join(",")}`;
403
+ if (!byKey.has(key)) byKey.set(key, mount);
404
+ }
405
+ return [...byKey.values()].sort((a, b) => {
406
+ const byPrefix = a.prefix.localeCompare(b.prefix);
407
+ if (byPrefix !== 0) return byPrefix;
408
+ return a.targetFiles.join(",").localeCompare(b.targetFiles.join(","));
409
+ });
282
410
  }
283
411
 
284
412
  function endpoint(method, routePath, file, type) {
@@ -304,8 +432,58 @@ function isSourceFile(file) {
304
432
  return ROUTE_EXTENSIONS.has(path.extname(normalized));
305
433
  }
306
434
 
435
+ function hasApplicableMount(file, mounts) {
436
+ return mounts.some((mount) => mountAppliesToFile(mount, file));
437
+ }
438
+
439
+ function mountAppliesToFile(mount, file) {
440
+ const normalized = normalizeFilePath(file);
441
+ if (mount.targetFiles.length > 0) return mount.targetFiles.includes(normalized);
442
+ return isLikelyRouterModulePath(normalized);
443
+ }
444
+
445
+ function isLikelyRouterModulePath(file) {
446
+ return (
447
+ file.startsWith("routes/") ||
448
+ file.startsWith("api/") ||
449
+ file.includes("/routes/") ||
450
+ file.includes("/api/")
451
+ );
452
+ }
453
+
454
+ function collectRouteReceivers(content) {
455
+ const all = new Set(["app", "server", "router", "apirouter", "routes", "route", "fastify"]);
456
+ const routers = new Set(["router", "apirouter", "routes", "route"]);
457
+ const expressAppRegex = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:express|fastify)\s*\(/g;
458
+ const expressRouterRegex = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:express\s*\.\s*Router|Router)\s*\(/g;
459
+
460
+ let match;
461
+ while ((match = expressAppRegex.exec(content)) !== null) {
462
+ all.add(match[1].toLowerCase());
463
+ }
464
+
465
+ while ((match = expressRouterRegex.exec(content)) !== null) {
466
+ all.add(match[1].toLowerCase());
467
+ routers.add(match[1].toLowerCase());
468
+ }
469
+
470
+ return { all, routers };
471
+ }
472
+
473
+ function isRouteReceiver(value, receivers = collectRouteReceivers("")) {
474
+ const normalized = String(value || "").toLowerCase();
475
+ return receivers.all.has(normalized) || normalized.endsWith("router") || normalized.endsWith("server");
476
+ }
477
+
478
+ function isRouterReceiver(value, receivers = collectRouteReceivers("")) {
479
+ const normalized = String(value || "").toLowerCase();
480
+ return receivers.routers.has(normalized) || normalized.endsWith("router");
481
+ }
482
+
307
483
  function isSastApiCandidate(file, content) {
308
484
  const normalized = normalizeFilePath(file);
485
+ if (hasVendoredPathSegment(normalized)) return false;
486
+
309
487
  return (
310
488
  isServerOrApiFile(normalized) ||
311
489
  normalized.startsWith("api/") ||
@@ -314,7 +492,15 @@ function isSastApiCandidate(file, content) {
314
492
  normalized.startsWith("controllers/") ||
315
493
  normalized.includes("/handlers/") ||
316
494
  normalized.includes("/controllers/")
317
- ) && /\b(?:req|request|res|response|ctx|context|NextRequest|NextResponse|Response|json)\b/.test(content);
495
+ ) && hasApiHandlerEvidence(content);
496
+ }
497
+
498
+ function hasApiHandlerEvidence(content) {
499
+ return API_HANDLER_EVIDENCE.some((regex) => regex.test(content));
500
+ }
501
+
502
+ function hasVendoredPathSegment(file) {
503
+ return /(?:^|\/)(?:vendor|vendors|third_party|third-party)\//i.test(file);
318
504
  }
319
505
 
320
506
  function hasExplicitRouteDeclaration(content) {
@@ -1,100 +1,99 @@
1
- const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0"]);
1
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0']);
2
2
 
3
3
  export const STRESS_DEFAULTS = {
4
- target: "http://localhost:3000",
5
- duration: 30,
6
- arrivalRate: 5,
7
- maxVusers: 50
4
+ target: 'http://localhost:3000',
5
+ duration: 30,
6
+ arrivalRate: 50,
7
+ maxVusers: 200,
8
8
  };
9
-
10
9
  export const STRESS_LIMITS = {
11
- duration: 300,
12
- arrivalRate: 50,
13
- maxVusers: 100
10
+ duration: 300,
11
+ arrivalRate: 100,
12
+ maxVusers: 400,
14
13
  };
15
14
 
16
15
  export function validateStressOptions(args = {}) {
17
- const target = normalizeTarget(args.target || STRESS_DEFAULTS.target);
18
- const parsedTarget = new URL(target);
19
- const targetPath = getExplicitTargetPath(parsedTarget);
20
- const duration = parsePositiveNumber(args.duration, STRESS_DEFAULTS.duration, "duration");
21
- const arrivalRate = parsePositiveNumber(args.arrivalRate, STRESS_DEFAULTS.arrivalRate, "arrival-rate");
22
- const maxVusers = parsePositiveInteger(args.maxVusers, STRESS_DEFAULTS.maxVusers, "max-vusers");
16
+ const target = normalizeTarget(args.target || STRESS_DEFAULTS.target);
17
+ const parsedTarget = new URL(target);
18
+ const targetPath = getExplicitTargetPath(parsedTarget);
19
+ const duration = parsePositiveNumber(args.duration, STRESS_DEFAULTS.duration, 'duration');
20
+ const arrivalRate = parsePositiveNumber(args.arrivalRate, STRESS_DEFAULTS.arrivalRate, 'arrival-rate');
21
+ const maxVusers = parsePositiveInteger(args.maxVusers, STRESS_DEFAULTS.maxVusers, 'max-vusers');
23
22
 
24
- assertWithinLimit(duration, STRESS_LIMITS.duration, "duration", "seconds");
25
- assertWithinLimit(arrivalRate, STRESS_LIMITS.arrivalRate, "arrival-rate", "requests/second");
26
- assertWithinLimit(maxVusers, STRESS_LIMITS.maxVusers, "max-vusers", "virtual users");
23
+ assertWithinLimit(duration, STRESS_LIMITS.duration, 'duration', 'seconds');
24
+ assertWithinLimit(arrivalRate, STRESS_LIMITS.arrivalRate, 'arrival-rate', 'requests/second');
25
+ assertWithinLimit(maxVusers, STRESS_LIMITS.maxVusers, 'max-vusers', 'virtual users');
27
26
 
28
- const local = isLocalTarget(target);
29
- if (!local && !args.iOwnThis) {
30
- throw new Error(
31
- "Refusing to stress-test an external target without --i-own-this. Only run this against systems you own or are explicitly authorized to test."
32
- );
33
- }
27
+ const local = isLocalTarget(target);
28
+ if (!local && !args.iOwnThis) {
29
+ throw new Error(
30
+ 'Refusing to stress-test an external target without --i-own-this. Only run this against systems you own or are explicitly authorized to test.',
31
+ );
32
+ }
34
33
 
35
- return {
36
- target,
37
- artilleryTarget: targetPath ? parsedTarget.origin : target,
38
- targetPath,
39
- duration,
40
- arrivalRate,
41
- maxVusers,
42
- iOwnThis: Boolean(args.iOwnThis),
43
- local
44
- };
34
+ return {
35
+ target,
36
+ artilleryTarget: targetPath ? parsedTarget.origin : target,
37
+ targetPath,
38
+ duration,
39
+ arrivalRate,
40
+ maxVusers,
41
+ iOwnThis: Boolean(args.iOwnThis),
42
+ local,
43
+ };
45
44
  }
46
45
 
47
46
  export function isLocalTarget(target) {
48
- let parsed;
49
- try {
50
- parsed = new URL(target);
51
- } catch {
52
- return false;
53
- }
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(target);
50
+ } catch {
51
+ return false;
52
+ }
54
53
 
55
- return LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
54
+ return LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
56
55
  }
57
56
 
58
57
  function normalizeTarget(value) {
59
- let parsed;
60
- try {
61
- parsed = new URL(value);
62
- } catch {
63
- throw new Error(`Invalid stress target URL: ${value}`);
64
- }
58
+ let parsed;
59
+ try {
60
+ parsed = new URL(value);
61
+ } catch {
62
+ throw new Error(`Invalid stress target URL: ${value}`);
63
+ }
65
64
 
66
- if (!["http:", "https:"].includes(parsed.protocol)) {
67
- throw new Error("Stress target must use http:// or https://.");
68
- }
65
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
66
+ throw new Error('Stress target must use http:// or https://.');
67
+ }
69
68
 
70
- return parsed.toString().replace(/\/$/, "");
69
+ return parsed.toString().replace(/\/$/, '');
71
70
  }
72
71
 
73
72
  function getExplicitTargetPath(parsed) {
74
- const pathname = parsed.pathname.replace(/\/$/, "") || "/";
75
- if (pathname === "/" && !parsed.search) return null;
76
- return `${pathname}${parsed.search}`;
73
+ const pathname = parsed.pathname.replace(/\/$/, '') || '/';
74
+ if (pathname === '/' && !parsed.search) return null;
75
+ return `${pathname}${parsed.search}`;
77
76
  }
78
77
 
79
78
  function parsePositiveNumber(value, fallback, label) {
80
- if (value === undefined || value === null || value === "") return fallback;
81
- const number = Number(value);
82
- if (!Number.isFinite(number) || number <= 0) {
83
- throw new Error(`Invalid --${label}: expected a positive number.`);
84
- }
85
- return number;
79
+ if (value === undefined || value === null || value === '') return fallback;
80
+ const number = Number(value);
81
+ if (!Number.isFinite(number) || number <= 0) {
82
+ throw new Error(`Invalid --${label}: expected a positive number.`);
83
+ }
84
+ return number;
86
85
  }
87
86
 
88
87
  function parsePositiveInteger(value, fallback, label) {
89
- const number = parsePositiveNumber(value, fallback, label);
90
- if (!Number.isInteger(number)) {
91
- throw new Error(`Invalid --${label}: expected a positive integer.`);
92
- }
93
- return number;
88
+ const number = parsePositiveNumber(value, fallback, label);
89
+ if (!Number.isInteger(number)) {
90
+ throw new Error(`Invalid --${label}: expected a positive integer.`);
91
+ }
92
+ return number;
94
93
  }
95
94
 
96
95
  function assertWithinLimit(value, limit, label, unit) {
97
- if (value > limit) {
98
- throw new Error(`Refusing --${label} ${value}. Maximum allowed is ${limit} ${unit}.`);
99
- }
96
+ if (value > limit) {
97
+ throw new Error(`Refusing --${label} ${value}. Maximum allowed is ${limit} ${unit}.`);
98
+ }
100
99
  }