itworksbut 0.5.0 → 0.7.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,200 @@
1
+ import path from "node:path";
2
+ import { DEFAULT_IGNORE } from "../core/config.js";
3
+ import { walkProject } from "../core/fileWalker.js";
4
+ import { readFileSafe } from "../utils/fs.js";
5
+
6
+ const HTTP_METHODS = ["get", "head", "post", "put", "patch", "delete"];
7
+ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
8
+ const ROUTE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
9
+ const DISCOVERY_IGNORE = [
10
+ ...DEFAULT_IGNORE,
11
+ "test/**",
12
+ "tests/**",
13
+ "__tests__/**",
14
+ "**/*.test.js",
15
+ "**/*.test.ts",
16
+ "**/*.spec.js",
17
+ "**/*.spec.ts"
18
+ ];
19
+
20
+ export async function discoverEndpoints(rootPath) {
21
+ const { textFiles } = await walkProject(rootPath, DISCOVERY_IGNORE);
22
+ return await discoverEndpointsFromFiles({
23
+ rootPath,
24
+ files: textFiles,
25
+ readFile: async (relativePath) => await readFileSafe(path.join(rootPath, relativePath))
26
+ });
27
+ }
28
+
29
+ export async function discoverEndpointsFromFiles({ rootPath, files, readFile }) {
30
+ const endpoints = [];
31
+
32
+ for (const file of files.filter(isSourceFile)) {
33
+ const content = await readFile(file);
34
+ if (content === null || content === undefined) continue;
35
+
36
+ endpoints.push(...discoverExpressEndpoints(file, content));
37
+ endpoints.push(...discoverFetchReferences(file, content));
38
+ endpoints.push(...discoverNextAppRouterEndpoints(file, content));
39
+ endpoints.push(...discoverNextPagesRouterEndpoints(file, content));
40
+ }
41
+
42
+ return classifyEndpoints(dedupeEndpoints(endpoints), rootPath);
43
+ }
44
+
45
+ export function classifyEndpoints(endpoints) {
46
+ const all = endpoints.map((endpoint) => {
47
+ const method = endpoint.method.toUpperCase();
48
+ const dynamic = isDynamicRoute(endpoint.path);
49
+ const unsafe = MUTATING_METHODS.has(method);
50
+ let status = "selected";
51
+ let reason;
52
+
53
+ if (unsafe) {
54
+ status = "skipped";
55
+ reason = "unsafe method";
56
+ } else if (dynamic) {
57
+ status = "skipped";
58
+ reason = "dynamic route requires parameter";
59
+ }
60
+
61
+ return {
62
+ ...endpoint,
63
+ method,
64
+ dynamic,
65
+ status,
66
+ reason
67
+ };
68
+ });
69
+
70
+ return {
71
+ status: all.length === 0 ? "skip" : "pass",
72
+ endpoints: all,
73
+ safeEndpoints: all.filter((endpoint) => endpoint.status === "selected"),
74
+ skippedEndpoints: all
75
+ .filter((endpoint) => endpoint.status === "skipped")
76
+ .map(({ method, path, reason, source, type }) => ({ method, path, reason, source, type }))
77
+ };
78
+ }
79
+
80
+ function discoverExpressEndpoints(file, content) {
81
+ const endpoints = [];
82
+ const methods = HTTP_METHODS.join("|");
83
+ const regex = new RegExp(`\\b(?:app|router|server)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
84
+ let match;
85
+
86
+ while ((match = regex.exec(content)) !== null) {
87
+ const routePath = normalizeRoutePath(match[3]);
88
+ if (!routePath) continue;
89
+ endpoints.push(endpoint(match[1], routePath, file, "express"));
90
+ }
91
+
92
+ return endpoints;
93
+ }
94
+
95
+ function discoverFetchReferences(file, content) {
96
+ const endpoints = [];
97
+ const regex = /\bfetch\s*\(\s*(['"`])(\/api\/[^'"`]+)\1/gi;
98
+ let match;
99
+
100
+ while ((match = regex.exec(content)) !== null) {
101
+ const routePath = normalizeRoutePath(match[2]);
102
+ if (!routePath) continue;
103
+ endpoints.push(endpoint("GET", routePath, file, "fetch-reference"));
104
+ }
105
+
106
+ return endpoints;
107
+ }
108
+
109
+ function discoverNextAppRouterEndpoints(file, content) {
110
+ const normalized = normalizeFilePath(file);
111
+ const match = normalized.match(/(?:^|\/)(?:src\/)?app\/api\/(.+)\/route\.(?:js|jsx|ts|tsx|mjs|cjs)$/);
112
+ if (!match) return [];
113
+
114
+ const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
115
+ const exportedMethods = discoverExportedRouteMethods(content);
116
+ const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
117
+
118
+ return methods.map((method) => endpoint(method, routePath, file, "next-app-router"));
119
+ }
120
+
121
+ function discoverNextPagesRouterEndpoints(file, content) {
122
+ const normalized = normalizeFilePath(file);
123
+ const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|jsx|ts|tsx|mjs|cjs)$/);
124
+ if (!match) return [];
125
+
126
+ const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
127
+ const guardedMethods = discoverMethodGuards(content);
128
+ const methods = guardedMethods.length > 0 ? guardedMethods : ["GET"];
129
+
130
+ return methods.map((method) => endpoint(method, routePath, file, "next-pages-router"));
131
+ }
132
+
133
+ function discoverExportedRouteMethods(content) {
134
+ const methods = new Set();
135
+ const regex = /\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE)\b/g;
136
+ let match;
137
+
138
+ while ((match = regex.exec(content)) !== null) {
139
+ methods.add(match[1]);
140
+ }
141
+
142
+ return [...methods];
143
+ }
144
+
145
+ function discoverMethodGuards(content) {
146
+ const methods = new Set();
147
+ const regex = /\b(?:req|request)\.method\s*(?:===|!==|==|!=)\s*['"`](GET|HEAD|POST|PUT|PATCH|DELETE)['"`]/gi;
148
+ let match;
149
+
150
+ while ((match = regex.exec(content)) !== null) {
151
+ methods.add(match[1].toUpperCase());
152
+ }
153
+
154
+ return [...methods];
155
+ }
156
+
157
+ function endpoint(method, routePath, file, type) {
158
+ return {
159
+ method: method.toUpperCase(),
160
+ path: routePath,
161
+ source: file,
162
+ type
163
+ };
164
+ }
165
+
166
+ function dedupeEndpoints(endpoints) {
167
+ const byKey = new Map();
168
+ for (const endpoint of endpoints) {
169
+ const key = `${endpoint.method.toUpperCase()} ${endpoint.path}`;
170
+ if (!byKey.has(key)) byKey.set(key, endpoint);
171
+ }
172
+ return [...byKey.values()].sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`));
173
+ }
174
+
175
+ function isSourceFile(file) {
176
+ const normalized = normalizeFilePath(file);
177
+ return ROUTE_EXTENSIONS.has(path.extname(normalized));
178
+ }
179
+
180
+ function normalizeFilePath(file) {
181
+ return String(file).replace(/\\/g, "/");
182
+ }
183
+
184
+ function normalizeRoutePath(value) {
185
+ if (!value || value.includes("${")) return null;
186
+ const pathValue = value.startsWith("/") ? value : `/${value}`;
187
+ return pathValue.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
188
+ }
189
+
190
+ function routeSegmentsToPath(value) {
191
+ return value
192
+ .split("/")
193
+ .filter(Boolean)
194
+ .map((segment) => segment.replace(/^\[(\.\.\.)?(.+)]$/, ":$2"))
195
+ .join("/");
196
+ }
197
+
198
+ function isDynamicRoute(routePath) {
199
+ return /(^|\/):[^/]+|\[[^/]+]|\*/.test(routePath);
200
+ }
@@ -0,0 +1,31 @@
1
+ export function createArtilleryConfig({ target, duration, arrivalRate, maxVusers, endpoints }) {
2
+ const flow = endpoints.map((endpoint) => ({
3
+ [endpoint.method.toLowerCase()]: {
4
+ url: endpoint.path
5
+ }
6
+ }));
7
+
8
+ return {
9
+ config: {
10
+ target,
11
+ phases: [
12
+ {
13
+ duration,
14
+ arrivalRate,
15
+ maxVusers
16
+ }
17
+ ],
18
+ defaults: {
19
+ headers: {
20
+ "user-agent": "ItWorksBut Stress"
21
+ }
22
+ }
23
+ },
24
+ scenarios: [
25
+ {
26
+ name: "Discovered API endpoints",
27
+ flow
28
+ }
29
+ ]
30
+ };
31
+ }
@@ -0,0 +1,80 @@
1
+ import { getChalk } from "../cli/terminal.js";
2
+
3
+ export function reportStressConsole(result, options = {}) {
4
+ const colors = getChalk(options);
5
+ const details = result.details || {};
6
+
7
+ if (!options.quiet) {
8
+ process.stdout.write(`${colors.bold("ItWorksBut Stress")}\n\n`);
9
+ process.stdout.write("Only run this against systems you own or are explicitly authorized to test.\n\n");
10
+ process.stdout.write(`Target: ${details.target}\n`);
11
+ process.stdout.write(`Duration: ${details.duration}s\n`);
12
+ process.stdout.write(`Arrival rate: ${details.arrivalRate} req/s\n`);
13
+ process.stdout.write(`Max virtual users: ${details.maxVusers}\n\n`);
14
+
15
+ writeDiscovery(details, colors);
16
+ writeEndpoints(details, colors);
17
+ }
18
+
19
+ writeSummary(result, colors);
20
+ }
21
+
22
+ function writeDiscovery(details, colors) {
23
+ const found = details.endpointsFound || 0;
24
+ const safe = details.safeEndpoints || 0;
25
+ const skipped = details.skippedEndpoints?.length || 0;
26
+ const unsafe = details.skippedEndpoints?.filter((endpoint) => endpoint.reason === "unsafe method").length || 0;
27
+ const dynamic = details.skippedEndpoints?.filter((endpoint) => endpoint.reason === "dynamic route requires parameter").length || 0;
28
+
29
+ process.stdout.write(`${colors.green("✓")} Endpoint discovery: ${found} ${found === 1 ? "endpoint" : "endpoints"} found\n`);
30
+ process.stdout.write(`${colors.green("✓")} Safe endpoints: ${safe} GET/HEAD ${safe === 1 ? "endpoint" : "endpoints"} selected\n`);
31
+ if (skipped > 0) {
32
+ process.stdout.write(`- Skipped: ${unsafe} unsafe methods, ${dynamic} dynamic routes\n`);
33
+ }
34
+ process.stdout.write("\n");
35
+ }
36
+
37
+ function writeEndpoints(details, colors) {
38
+ const testedEndpoints = details.testedEndpoints || [];
39
+ if (testedEndpoints.length === 0) {
40
+ process.stdout.write("Running Artillery: skipped\n\n");
41
+ return;
42
+ }
43
+
44
+ process.stdout.write("Running Artillery: complete\n\n");
45
+ for (const endpoint of testedEndpoints) {
46
+ const symbol = endpoint.status === "pass" ? colors.green("✓") : endpoint.status === "warn" ? colors.yellow("⚠") : colors.red("✕");
47
+ process.stdout.write(`${symbol} ${endpoint.method} ${endpoint.path}\n`);
48
+ process.stdout.write(` p95: ${formatMs(endpoint.p95)}\n`);
49
+ process.stdout.write(` p99: ${formatMs(endpoint.p99)}\n`);
50
+ process.stdout.write(` errors: ${endpoint.errors}\n`);
51
+ process.stdout.write(` error rate: ${formatPercent(endpoint.errorRate)}\n\n`);
52
+ }
53
+ }
54
+
55
+ function writeSummary(result, colors) {
56
+ const details = result.details || {};
57
+ const warnings = details.warnings || 0;
58
+ const failed = details.failed || 0;
59
+ const tested = details.testedEndpoints?.length || 0;
60
+ const skipped = details.skippedEndpoints?.length || 0;
61
+
62
+ process.stdout.write("Summary:\n");
63
+ process.stdout.write(`${colors.green("✓")} Tested endpoints: ${tested}\n`);
64
+ process.stdout.write(`${colors.yellow("⚠")} Warnings: ${warnings}\n`);
65
+ process.stdout.write(`${colors.red("✕")} Failed: ${failed}\n`);
66
+ process.stdout.write(`- Skipped: ${skipped}\n`);
67
+ process.stdout.write(`- Status: ${result.status}\n`);
68
+ process.stdout.write(`- ${result.summary}\n`);
69
+ if (details.artilleryError) {
70
+ process.stdout.write(`- Artillery error: ${details.artilleryError}\n`);
71
+ }
72
+ }
73
+
74
+ function formatMs(value) {
75
+ return value === null || value === undefined ? "n/a" : `${Math.round(value)} ms`;
76
+ }
77
+
78
+ function formatPercent(value) {
79
+ return `${Number(value || 0).toFixed(Number.isInteger(value) ? 0 : 2).replace(/\.00$/, "")}%`;
80
+ }
@@ -0,0 +1,155 @@
1
+ const DEFAULT_THRESHOLDS = {
2
+ p95WarnMs: 500,
3
+ p95FailMs: 2000,
4
+ p99WarnMs: 1500,
5
+ errorRateWarn: 0,
6
+ errorRateFail: 10
7
+ };
8
+
9
+ export function parseArtilleryResult(artilleryResult, endpoints, options = {}) {
10
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds || {}) };
11
+
12
+ if (!artilleryResult?.report) {
13
+ return {
14
+ status: "fail",
15
+ summary: "Artillery did not produce a parseable report.",
16
+ testedEndpoints: endpoints.map((endpoint) => ({
17
+ method: endpoint.method,
18
+ path: endpoint.path,
19
+ status: "fail",
20
+ requests: 0,
21
+ p95: null,
22
+ p99: null,
23
+ errors: 1,
24
+ errorRate: 100
25
+ })),
26
+ warnings: 0,
27
+ failed: endpoints.length,
28
+ error: trimText(artilleryResult?.stderr || artilleryResult?.stdout || "Artillery failed.")
29
+ };
30
+ }
31
+
32
+ const aggregate = getAggregate(artilleryResult.report);
33
+ const counters = aggregate.counters || {};
34
+ const summaries = aggregate.summaries || {};
35
+ const fallbackRequests = countRequests(counters);
36
+ const fallbackErrors = countErrors(counters);
37
+
38
+ const testedEndpoints = endpoints.map((endpoint) => {
39
+ const summary = findEndpointSummary(summaries, endpoint) || summaries["http.response_time"] || {};
40
+ const requests = countEndpointRequests(counters, endpoint) ?? fallbackRequests;
41
+ const errors = countEndpointErrors(counters, endpoint) ?? fallbackErrors;
42
+ const errorRate = requests > 0 ? round((errors / requests) * 100, 2) : (errors > 0 ? 100 : 0);
43
+ const p95 = numeric(summary.p95);
44
+ const p99 = numeric(summary.p99);
45
+ const status = classifyEndpoint({ p95, p99, errorRate, errors }, thresholds);
46
+
47
+ return {
48
+ method: endpoint.method,
49
+ path: endpoint.path,
50
+ status,
51
+ requests,
52
+ p95,
53
+ p99,
54
+ errors,
55
+ errorRate
56
+ };
57
+ });
58
+
59
+ const warnings = testedEndpoints.filter((endpoint) => endpoint.status === "warn").length;
60
+ const failed = testedEndpoints.filter((endpoint) => endpoint.status === "fail").length;
61
+ const status = !artilleryResult.ok || failed > 0 ? "fail" : warnings > 0 ? "warn" : "pass";
62
+ const summary = summarize(testedEndpoints.length, warnings, failed, artilleryResult);
63
+
64
+ return {
65
+ status,
66
+ summary,
67
+ testedEndpoints,
68
+ warnings,
69
+ failed,
70
+ error: artilleryResult.ok ? undefined : trimText(artilleryResult.stderr || artilleryResult.stdout)
71
+ };
72
+ }
73
+
74
+ function getAggregate(report) {
75
+ if (report?.aggregate) return report.aggregate;
76
+ if (Array.isArray(report?.intermediate) && report.intermediate.length > 0) {
77
+ return report.intermediate[report.intermediate.length - 1] || {};
78
+ }
79
+ return report || {};
80
+ }
81
+
82
+ function findEndpointSummary(summaries, endpoint) {
83
+ const methodPath = `${endpoint.method} ${endpoint.path}`;
84
+ const candidates = Object.entries(summaries);
85
+ const match = candidates.find(([key]) => key.includes(methodPath));
86
+ if (match) return match[1];
87
+
88
+ const pathMatch = candidates.find(([key]) => key.includes(endpoint.path));
89
+ return pathMatch ? pathMatch[1] : null;
90
+ }
91
+
92
+ function countRequests(counters) {
93
+ if (Number.isFinite(counters["http.requests"])) return counters["http.requests"];
94
+ if (Number.isFinite(counters["http.responses"])) return counters["http.responses"];
95
+
96
+ return Object.entries(counters)
97
+ .filter(([key]) => /^http\.codes\.\d+$/.test(key))
98
+ .reduce((total, [, value]) => total + numeric(value), 0);
99
+ }
100
+
101
+ function countErrors(counters) {
102
+ const explicit = Object.entries(counters)
103
+ .filter(([key]) => key.startsWith("http.errors"))
104
+ .reduce((total, [, value]) => total + numeric(value), 0);
105
+ const statusErrors = Object.entries(counters)
106
+ .filter(([key]) => /^http\.codes\.[45]\d\d$/.test(key))
107
+ .reduce((total, [, value]) => total + numeric(value), 0);
108
+
109
+ return explicit + statusErrors;
110
+ }
111
+
112
+ function countEndpointRequests(counters, endpoint) {
113
+ return countEndpointMetric(counters, endpoint, ["requests", "responses"]);
114
+ }
115
+
116
+ function countEndpointErrors(counters, endpoint) {
117
+ return countEndpointMetric(counters, endpoint, ["errors"]);
118
+ }
119
+
120
+ function countEndpointMetric(counters, endpoint, names) {
121
+ const methodPath = `${endpoint.method} ${endpoint.path}`;
122
+ const matches = Object.entries(counters).filter(([key]) => {
123
+ return key.includes(methodPath) && names.some((name) => key.toLowerCase().includes(name));
124
+ });
125
+ if (!matches.length) return null;
126
+ return matches.reduce((total, [, value]) => total + numeric(value), 0);
127
+ }
128
+
129
+ function classifyEndpoint({ p95, p99, errorRate, errors }, thresholds) {
130
+ if (errorRate >= thresholds.errorRateFail || p95 >= thresholds.p95FailMs) return "fail";
131
+ if (errorRate > thresholds.errorRateWarn || errors > 0 || p95 > thresholds.p95WarnMs || p99 > thresholds.p99WarnMs) {
132
+ return "warn";
133
+ }
134
+ return "pass";
135
+ }
136
+
137
+ function summarize(tested, warnings, failed, artilleryResult) {
138
+ if (!artilleryResult.ok) return `Artillery failed after testing ${tested} ${tested === 1 ? "endpoint" : "endpoints"}.`;
139
+ return `${tested} ${tested === 1 ? "endpoint" : "endpoints"} tested, ${warnings} ${warnings === 1 ? "warning" : "warnings"}, ${failed} failed`;
140
+ }
141
+
142
+ function numeric(value) {
143
+ const number = Number(value);
144
+ return Number.isFinite(number) ? number : 0;
145
+ }
146
+
147
+ function round(value, digits) {
148
+ const factor = 10 ** digits;
149
+ return Math.round(value * factor) / factor;
150
+ }
151
+
152
+ function trimText(value) {
153
+ const normalized = String(value || "").trim();
154
+ return normalized.length > 1000 ? `${normalized.slice(0, 1000)}...` : normalized;
155
+ }
@@ -0,0 +1,28 @@
1
+ export function detectOutdatedPackageManager({ packageJson, files = [] } = {}) {
2
+ if (!packageJson) {
3
+ return {
4
+ status: "skip",
5
+ manager: null,
6
+ summary: "skipped, no package.json found"
7
+ };
8
+ }
9
+
10
+ if (files.includes("pnpm-lock.yaml")) return packageManager("pnpm");
11
+ if (files.includes("yarn.lock")) return packageManager("yarn");
12
+ if (files.includes("package-lock.json")) return packageManager("npm");
13
+ return packageManager("npm");
14
+ }
15
+
16
+ export function getOutdatedCommand(manager) {
17
+ if (manager === "pnpm") return { command: "pnpm", args: ["outdated", "--json"] };
18
+ if (manager === "yarn") return { command: "yarn", args: ["outdated", "--json"] };
19
+ return { command: "npm", args: ["outdated", "--json"] };
20
+ }
21
+
22
+ function packageManager(manager) {
23
+ return {
24
+ status: "run",
25
+ manager,
26
+ ...getOutdatedCommand(manager)
27
+ };
28
+ }
@@ -0,0 +1,90 @@
1
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0"]);
2
+
3
+ export const STRESS_DEFAULTS = {
4
+ target: "http://localhost:3000",
5
+ duration: 30,
6
+ arrivalRate: 5,
7
+ maxVusers: 50
8
+ };
9
+
10
+ export const STRESS_LIMITS = {
11
+ duration: 300,
12
+ arrivalRate: 50,
13
+ maxVusers: 100
14
+ };
15
+
16
+ export function validateStressOptions(args = {}) {
17
+ const target = normalizeTarget(args.target || STRESS_DEFAULTS.target);
18
+ const duration = parsePositiveNumber(args.duration, STRESS_DEFAULTS.duration, "duration");
19
+ const arrivalRate = parsePositiveNumber(args.arrivalRate, STRESS_DEFAULTS.arrivalRate, "arrival-rate");
20
+ const maxVusers = parsePositiveInteger(args.maxVusers, STRESS_DEFAULTS.maxVusers, "max-vusers");
21
+
22
+ assertWithinLimit(duration, STRESS_LIMITS.duration, "duration", "seconds");
23
+ assertWithinLimit(arrivalRate, STRESS_LIMITS.arrivalRate, "arrival-rate", "requests/second");
24
+ assertWithinLimit(maxVusers, STRESS_LIMITS.maxVusers, "max-vusers", "virtual users");
25
+
26
+ const local = isLocalTarget(target);
27
+ if (!local && !args.iOwnThis) {
28
+ throw new Error(
29
+ "Refusing to stress-test an external target without --i-own-this. Only run this against systems you own or are explicitly authorized to test."
30
+ );
31
+ }
32
+
33
+ return {
34
+ target,
35
+ duration,
36
+ arrivalRate,
37
+ maxVusers,
38
+ iOwnThis: Boolean(args.iOwnThis),
39
+ local
40
+ };
41
+ }
42
+
43
+ export function isLocalTarget(target) {
44
+ let parsed;
45
+ try {
46
+ parsed = new URL(target);
47
+ } catch {
48
+ return false;
49
+ }
50
+
51
+ return LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
52
+ }
53
+
54
+ function normalizeTarget(value) {
55
+ let parsed;
56
+ try {
57
+ parsed = new URL(value);
58
+ } catch {
59
+ throw new Error(`Invalid stress target URL: ${value}`);
60
+ }
61
+
62
+ if (!["http:", "https:"].includes(parsed.protocol)) {
63
+ throw new Error("Stress target must use http:// or https://.");
64
+ }
65
+
66
+ return parsed.toString().replace(/\/$/, "");
67
+ }
68
+
69
+ function parsePositiveNumber(value, fallback, label) {
70
+ if (value === undefined || value === null || value === "") return fallback;
71
+ const number = Number(value);
72
+ if (!Number.isFinite(number) || number <= 0) {
73
+ throw new Error(`Invalid --${label}: expected a positive number.`);
74
+ }
75
+ return number;
76
+ }
77
+
78
+ function parsePositiveInteger(value, fallback, label) {
79
+ const number = parsePositiveNumber(value, fallback, label);
80
+ if (!Number.isInteger(number)) {
81
+ throw new Error(`Invalid --${label}: expected a positive integer.`);
82
+ }
83
+ return number;
84
+ }
85
+
86
+ function assertWithinLimit(value, limit, label, unit) {
87
+ if (value > limit) {
88
+ throw new Error(`Refusing --${label} ${value}. Maximum allowed is ${limit} ${unit}.`);
89
+ }
90
+ }