itworksbut 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -49,6 +49,8 @@ Common commands:
49
49
 
50
50
  ```sh
51
51
  itworksbut scan --path .
52
+ itworksbut deps
53
+ itworksbut stress
52
54
  itworksbut scan --fail-on high
53
55
  itworksbut scan --json
54
56
  itworksbut scan --sarif > itworksbut.sarif
@@ -63,11 +65,20 @@ itworksbut --version
63
65
 
64
66
  ```text
65
67
  itworksbut scan [options]
68
+ itworksbut deps [options]
69
+ itworksbut stress [options]
66
70
  ```
67
71
 
72
+ - `deps`: Run only dependency checks, including lockfile hygiene, install-script risk, audit script availability, and outdated packages.
73
+ - `stress`: Discover API endpoints and run a controlled Artillery load test against local or explicitly authorized targets.
68
74
  - `--path <path>`: Scan a specific project directory. Defaults to the current directory.
69
75
  - `--config <path>`: Use a custom config file. Defaults to `itworksbut.config.json` when present.
70
76
  - `--fail-on <severity>`: Exit with code `1` when a finding at or above the severity exists. Levels: `critical`, `high`, `medium`, `low`, `info`. Default: `low`.
77
+ - `--target <url>`: Stress-test target. Defaults to `http://localhost:3000`. External targets require `--i-own-this`.
78
+ - `--duration <seconds>`: Stress-test duration. Default `30`, maximum `300`.
79
+ - `--arrival-rate <number>`: Arrival rate in requests per second. Default `5`, maximum `50`.
80
+ - `--max-vusers <number>`: Virtual user cap. Default `50`, maximum `100`.
81
+ - `--i-own-this`: Required for non-local stress-test targets.
71
82
  - `--json`: Print machine-readable JSON only. No banner, colors, spinner, table, or extra text.
72
83
  - `--sarif`: Print SARIF JSON for GitHub Code Scanning. No banner, colors, spinner, table, or extra text.
73
84
  - `--todo`: Write an AI-ready `todo.md` into the scanned project with prioritized findings, fix prompts, and acceptance criteria.
@@ -119,6 +130,16 @@ itworksbut scan --report
119
130
 
120
131
  This writes `report.md` to the current working directory with check statuses, summaries, details, and recommendations.
121
132
 
133
+ To run a controlled API stress test:
134
+
135
+ ```sh
136
+ itworksbut stress
137
+ itworksbut stress --target https://my-own-api.example --i-own-this
138
+ itworksbut stress --report
139
+ ```
140
+
141
+ `stress` only tests local targets by default. For external hosts, pass `--i-own-this` to confirm that you own the target or have explicit authorization. Mutating endpoints such as `POST`, `PUT`, `PATCH`, and `DELETE` are discovered but skipped automatically.
142
+
122
143
  ## GitHub Actions
123
144
 
124
145
  ```yaml
package/bin/itworksbut.js CHANGED
@@ -11,6 +11,7 @@ import { reportJson } from '../src/reporters/jsonReporter.js';
11
11
  import { reportSarif } from '../src/reporters/sarifReporter.js';
12
12
  import { writeTodoReport } from '../src/reporters/todoReporter.js';
13
13
  import { writeMarkdownReport } from '../src/reporters/markdownReport.js';
14
+ import { runStressCommand } from '../src/commands/stress.js';
14
15
 
15
16
  async function main() {
16
17
  const args = parseArgs(process.argv.slice(2));
@@ -25,7 +26,11 @@ async function main() {
25
26
  return 0;
26
27
  }
27
28
 
28
- if (args.command !== 'scan') {
29
+ if (args.command === 'stress') {
30
+ return await runStressCommand(args);
31
+ }
32
+
33
+ if (!['scan', 'deps'].includes(args.command)) {
29
34
  printUsage();
30
35
  return 2;
31
36
  }
@@ -42,6 +47,7 @@ async function main() {
42
47
  configPath: args.config,
43
48
  failOn: args.failOn,
44
49
  verbose: args.verbose,
50
+ categories: args.command === 'deps' ? ['dependencies'] : undefined,
45
51
  });
46
52
  if (spinner) spinner.succeed('Scan complete. Now the receipts.');
47
53
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "itworksbut",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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": {
@@ -13,7 +13,7 @@
13
13
  "itworksbut.config.json"
14
14
  ],
15
15
  "engines": {
16
- "node": ">=20"
16
+ "node": ">=26"
17
17
  },
18
18
  "scripts": {
19
19
  "test": "node --test",
@@ -36,6 +36,10 @@
36
36
  ],
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
+ "@smithy/config-resolver": "4.5.3",
40
+ "@smithy/node-config-provider": "4.4.3",
41
+ "@smithy/property-provider": "4.2.3",
42
+ "artillery": "2.0.30",
39
43
  "boxen": "^8.0.1",
40
44
  "chalk": "^5.6.2",
41
45
  "cli-table3": "^0.6.5",
package/src/cli/output.js CHANGED
@@ -5,12 +5,19 @@ export function printUsage() {
5
5
 
6
6
  Usage:
7
7
  itworksbut scan [options]
8
+ itworksbut deps [options]
9
+ itworksbut stress [options]
8
10
 
9
11
  Options:
10
12
  --path <path> Project path to scan. Defaults to current directory.
11
13
  --config <path> Optional itworksbut.config.json path.
12
14
  --fail-on <level> Exit 1 when findings meet or exceed this severity.
13
15
  Levels: critical, high, medium, low, info. Default: low.
16
+ --target <url> Stress-test target. Defaults to http://localhost:3000.
17
+ --duration <sec> Stress-test duration. Default: 30, max: 300.
18
+ --arrival-rate <n> Stress-test arrival rate. Default: 5, max: 50.
19
+ --max-vusers <n> Stress-test virtual user cap. Default: 50, max: 100.
20
+ --i-own-this Required to stress-test non-local targets.
14
21
  --json Print machine-readable JSON.
15
22
  --sarif Print SARIF for GitHub Code Scanning.
16
23
  --todo Write an AI-ready todo.md to the scanned project.
@@ -1,5 +1,5 @@
1
- const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path"]);
2
- const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--report", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
1
+ const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path", "--target", "--duration", "--arrival-rate", "--max-vusers"]);
2
+ const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--report", "--i-own-this", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
3
3
 
4
4
  export function parseArgs(argv) {
5
5
  const args = {
@@ -11,6 +11,11 @@ export function parseArgs(argv) {
11
11
  sarif: false,
12
12
  todo: false,
13
13
  report: false,
14
+ target: undefined,
15
+ duration: undefined,
16
+ arrivalRate: undefined,
17
+ maxVusers: undefined,
18
+ iOwnThis: false,
14
19
  verbose: false,
15
20
  noColor: false,
16
21
  noBanner: false,
@@ -50,6 +55,7 @@ export function parseArgs(argv) {
50
55
  if (token === "--sarif") args.sarif = true;
51
56
  if (token === "--todo") args.todo = true;
52
57
  if (token === "--report") args.report = true;
58
+ if (token === "--i-own-this") args.iOwnThis = true;
53
59
  if (token === "--verbose") args.verbose = true;
54
60
  if (token === "--no-color") args.noColor = true;
55
61
  if (token === "--no-banner") args.noBanner = true;
@@ -71,5 +77,9 @@ function assignValue(args, flag, value) {
71
77
  if (flag === "--fail-on") args.failOn = value;
72
78
  else if (flag === "--config") args.config = value;
73
79
  else if (flag === "--path") args.path = value;
80
+ else if (flag === "--target") args.target = value;
81
+ else if (flag === "--duration") args.duration = value;
82
+ else if (flag === "--arrival-rate") args.arrivalRate = value;
83
+ else if (flag === "--max-vusers") args.maxVusers = value;
74
84
  else throw new Error(`Unknown argument: ${flag}`);
75
85
  }
@@ -0,0 +1,142 @@
1
+ import path from "node:path";
2
+ import { validateStressOptions } from "../utils/targetSafety.js";
3
+ import { discoverEndpoints } from "../stress/discoverEndpoints.js";
4
+ import { createArtilleryConfig } from "../stress/stressConfig.js";
5
+ import { runArtillery } from "../stress/artilleryRunner.js";
6
+ import { parseArtilleryResult } from "../stress/stressResultParser.js";
7
+ import { reportStressConsole } from "../stress/stressRenderer.js";
8
+ import { writeStressMarkdownReport } from "../reporters/stressMarkdownReport.js";
9
+
10
+ export async function runStressCommand(args, options = {}) {
11
+ if (args.todo || args.sarif) {
12
+ throw new Error("The stress command supports console output, --json, and --report.");
13
+ }
14
+
15
+ const result = await runStress({
16
+ rootPath: path.resolve(args.path || "."),
17
+ ...validateStressOptions(args)
18
+ }, options);
19
+
20
+ if (args.json) {
21
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
22
+ } else {
23
+ reportStressConsole(result, args);
24
+ }
25
+
26
+ if (args.report) {
27
+ const report = await writeStressMarkdownReport(result);
28
+ if (!args.json && !args.quiet) {
29
+ const verb = report.overwritten ? "Overwrote" : "Wrote";
30
+ process.stdout.write(`${verb} stress report: ${report.filePath}\n`);
31
+ }
32
+ }
33
+
34
+ return result.status === "fail" ? 1 : 0;
35
+ }
36
+
37
+ export async function runStress(options, runnerOptions = {}) {
38
+ const startedAt = new Date();
39
+ const discovery = withExplicitTargetEndpoint(await discoverEndpoints(options.rootPath), options.targetPath);
40
+ const baseDetails = {
41
+ target: options.target,
42
+ artilleryTarget: options.artilleryTarget || options.target,
43
+ duration: options.duration,
44
+ arrivalRate: options.arrivalRate,
45
+ maxVusers: options.maxVusers,
46
+ endpointsFound: discovery.endpoints.length,
47
+ safeEndpoints: discovery.safeEndpoints.length,
48
+ skippedEndpoints: discovery.skippedEndpoints,
49
+ startedAt: startedAt.toISOString()
50
+ };
51
+
52
+ if (discovery.endpoints.length === 0) {
53
+ return stressResult({
54
+ status: "skip",
55
+ summary: "No API endpoints found.",
56
+ details: {
57
+ ...baseDetails,
58
+ testedEndpoints: [],
59
+ warnings: 0,
60
+ failed: 0,
61
+ completedAt: new Date().toISOString()
62
+ }
63
+ });
64
+ }
65
+
66
+ if (discovery.safeEndpoints.length === 0) {
67
+ return stressResult({
68
+ status: "fail",
69
+ summary: "No safe GET/HEAD endpoints can be tested automatically.",
70
+ details: {
71
+ ...baseDetails,
72
+ testedEndpoints: [],
73
+ warnings: 0,
74
+ failed: 1,
75
+ completedAt: new Date().toISOString()
76
+ }
77
+ });
78
+ }
79
+
80
+ const config = createArtilleryConfig({
81
+ target: options.artilleryTarget || options.target,
82
+ duration: options.duration,
83
+ arrivalRate: options.arrivalRate,
84
+ maxVusers: options.maxVusers,
85
+ endpoints: discovery.safeEndpoints
86
+ });
87
+
88
+ const artillery = await runArtillery(config, {
89
+ rootPath: options.rootPath,
90
+ duration: options.duration,
91
+ ...runnerOptions
92
+ });
93
+ const parsed = parseArtilleryResult(artillery, discovery.safeEndpoints);
94
+
95
+ return stressResult({
96
+ status: parsed.status,
97
+ summary: parsed.summary,
98
+ details: {
99
+ ...baseDetails,
100
+ testedEndpoints: parsed.testedEndpoints,
101
+ warnings: parsed.warnings,
102
+ failed: parsed.failed,
103
+ artilleryError: parsed.error,
104
+ completedAt: new Date().toISOString()
105
+ }
106
+ });
107
+ }
108
+
109
+ function stressResult(result) {
110
+ return {
111
+ id: "stress-test",
112
+ title: "API stress test",
113
+ ...result
114
+ };
115
+ }
116
+
117
+ function withExplicitTargetEndpoint(discovery, targetPath) {
118
+ if (!targetPath) return discovery;
119
+ if (discovery.endpoints.some((endpoint) => endpoint.method === "GET" && endpoint.path === targetPath)) {
120
+ return discovery;
121
+ }
122
+
123
+ const explicitEndpoint = {
124
+ method: "GET",
125
+ path: targetPath,
126
+ source: "--target",
127
+ type: "explicit-target",
128
+ dynamic: false,
129
+ status: "selected"
130
+ };
131
+
132
+ return {
133
+ status: "pass",
134
+ endpoints: sortEndpoints([...discovery.endpoints, explicitEndpoint]),
135
+ safeEndpoints: sortEndpoints([...discovery.safeEndpoints, explicitEndpoint]),
136
+ skippedEndpoints: discovery.skippedEndpoints
137
+ };
138
+ }
139
+
140
+ function sortEndpoints(endpoints) {
141
+ return [...endpoints].sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`));
142
+ }
@@ -7,11 +7,14 @@ import { packageInfo } from "./packageInfo.js";
7
7
  export async function scanProject(options = {}) {
8
8
  const startedAt = new Date();
9
9
  const context = await createContext(options);
10
+ const includedCategories = normalizeFilter(options.categories);
10
11
  const findings = [];
11
12
  const checkResults = [];
12
13
  const warnings = [];
13
14
 
14
15
  for (const check of checks) {
16
+ if (includedCategories && !includedCategories.has(check.category)) continue;
17
+
15
18
  if (context.config.checks[check.id] === false) {
16
19
  checkResults.push(normalizeCheckResult(check, {
17
20
  status: "skip",
@@ -75,6 +78,7 @@ export async function scanProject(options = {}) {
75
78
  version: packageInfo.version,
76
79
  rootPath: context.rootPath,
77
80
  packageName: context.packageJson?.name,
81
+ categories: includedCategories ? [...includedCategories] : undefined,
78
82
  packageManager: context.packageManager,
79
83
  gitAvailable: context.gitAvailable,
80
84
  filesScanned: context.allFiles.length,
@@ -84,3 +88,8 @@ export async function scanProject(options = {}) {
84
88
  }
85
89
  };
86
90
  }
91
+
92
+ function normalizeFilter(values) {
93
+ if (!Array.isArray(values) || values.length === 0) return null;
94
+ return new Set(values.map((value) => String(value).trim()).filter(Boolean));
95
+ }
@@ -0,0 +1,157 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const ANSI_PATTERN = /\u001B\[[0-?]*[ -/]*[@-~]/g;
5
+
6
+ export async function writeStressMarkdownReport(result, options = {}) {
7
+ const filePath = options.filePath || path.join(options.directoryPath || process.cwd(), "stress-report.md");
8
+ let overwritten = false;
9
+
10
+ try {
11
+ await fs.access(filePath);
12
+ overwritten = true;
13
+ } catch {
14
+ overwritten = false;
15
+ }
16
+
17
+ await fs.writeFile(filePath, reportStressMarkdown(result), "utf8");
18
+ return { filePath, overwritten };
19
+ }
20
+
21
+ export function reportStressMarkdown(result) {
22
+ const details = result.details || {};
23
+ const tested = details.testedEndpoints || [];
24
+ const skipped = details.skippedEndpoints || [];
25
+
26
+ return stripAnsi(`${[
27
+ "# ItWorksBut Stress Report",
28
+ "",
29
+ `Generated: ${formatTimestamp(details.completedAt || new Date())}`,
30
+ "",
31
+ `Target: ${details.target || "unknown"}`,
32
+ `Duration: ${details.duration ?? "unknown"}s`,
33
+ `Arrival rate: ${details.arrivalRate ?? "unknown"} req/s`,
34
+ `Max virtual users: ${details.maxVusers ?? "unknown"}`,
35
+ "",
36
+ "## Summary",
37
+ "",
38
+ "| Metric | Value |",
39
+ "|---|---:|",
40
+ `| Endpoints found | ${details.endpointsFound || 0} |`,
41
+ `| Endpoints tested | ${tested.length} |`,
42
+ `| Endpoints skipped | ${skipped.length} |`,
43
+ `| Warnings | ${details.warnings || 0} |`,
44
+ `| Failed | ${details.failed || 0} |`,
45
+ "",
46
+ "## Tested Endpoints",
47
+ "",
48
+ renderTestedEndpoints(tested),
49
+ "",
50
+ "## Skipped Endpoints",
51
+ "",
52
+ renderSkippedEndpoints(skipped),
53
+ renderArtilleryError(details.artilleryError),
54
+ renderRecommendations(result),
55
+ ].filter((line) => line !== null && line !== undefined).join("\n")}\n`);
56
+ }
57
+
58
+ function renderTestedEndpoints(endpoints) {
59
+ if (!endpoints.length) return "None.";
60
+
61
+ return [
62
+ "| Method | Path | Status | p95 | p99 | Errors | Error Rate |",
63
+ "|---|---|---|---:|---:|---:|---:|",
64
+ ...endpoints.map((endpoint) => (
65
+ `| ${escapeTable(endpoint.method)} | ${escapeTable(endpoint.path)} | ${escapeTable(endpoint.status)} | ${formatMs(endpoint.p95)} | ${formatMs(endpoint.p99)} | ${endpoint.errors || 0} | ${formatPercent(endpoint.errorRate)} |`
66
+ ))
67
+ ].join("\n");
68
+ }
69
+
70
+ function renderSkippedEndpoints(endpoints) {
71
+ if (!endpoints.length) return "None.";
72
+
73
+ return [
74
+ "| Method | Path | Reason |",
75
+ "|---|---|---|",
76
+ ...endpoints.map((endpoint) => (
77
+ `| ${escapeTable(endpoint.method)} | ${escapeTable(endpoint.path)} | ${escapeTable(endpoint.reason)} |`
78
+ ))
79
+ ].join("\n");
80
+ }
81
+
82
+ function renderRecommendations(result) {
83
+ const details = result.details || {};
84
+ const recommendations = [];
85
+
86
+ if ((details.warnings || 0) > 0) {
87
+ recommendations.push("Review slow endpoints.");
88
+ recommendations.push("Add caching or pagination where needed.");
89
+ }
90
+ if ((details.failed || 0) > 0 || result.status === "fail") {
91
+ recommendations.push("Investigate failed requests before increasing load.");
92
+ }
93
+ if ((details.skippedEndpoints || []).some((endpoint) => endpoint.reason === "unsafe method")) {
94
+ recommendations.push("Test mutating endpoints manually in a safe staging environment.");
95
+ }
96
+ recommendations.push("Add rate limits before public deployment.");
97
+
98
+ return [
99
+ "",
100
+ "## Recommendations",
101
+ "",
102
+ ...unique(recommendations).map((recommendation) => `- ${recommendation}`),
103
+ ""
104
+ ].join("\n");
105
+ }
106
+
107
+ function renderArtilleryError(error) {
108
+ if (!error) return "";
109
+
110
+ return [
111
+ "",
112
+ "## Artillery Error",
113
+ "",
114
+ error,
115
+ ""
116
+ ].join("\n");
117
+ }
118
+
119
+ function formatTimestamp(value) {
120
+ const date = value instanceof Date ? value : new Date(value);
121
+ if (Number.isNaN(date.getTime())) return String(value);
122
+
123
+ const pad = (number) => String(number).padStart(2, "0");
124
+ return [
125
+ date.getFullYear(),
126
+ "-",
127
+ pad(date.getMonth() + 1),
128
+ "-",
129
+ pad(date.getDate()),
130
+ " ",
131
+ pad(date.getHours()),
132
+ ":",
133
+ pad(date.getMinutes()),
134
+ ":",
135
+ pad(date.getSeconds())
136
+ ].join("");
137
+ }
138
+
139
+ function formatMs(value) {
140
+ return value === null || value === undefined ? "n/a" : `${Math.round(value)} ms`;
141
+ }
142
+
143
+ function formatPercent(value) {
144
+ return `${Number(value || 0).toFixed(Number.isInteger(value) ? 0 : 2).replace(/\.00$/, "")}%`;
145
+ }
146
+
147
+ function escapeTable(value) {
148
+ return stripAnsi(String(value ?? "unknown")).replace(/\|/g, "\\|");
149
+ }
150
+
151
+ function stripAnsi(value) {
152
+ return String(value).replace(ANSI_PATTERN, "");
153
+ }
154
+
155
+ function unique(values) {
156
+ return [...new Set(values.filter(Boolean))];
157
+ }
@@ -0,0 +1,83 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { fileExists } from "../utils/fs.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const TOOL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
11
+
12
+ export async function runArtillery(config, options = {}) {
13
+ if (options.execute) return await options.execute(config, options);
14
+
15
+ const rootPath = options.rootPath || process.cwd();
16
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "itworksbut-stress-"));
17
+ const configPath = path.join(tempDir, "artillery-config.json");
18
+ const outputPath = path.join(tempDir, "artillery-report.json");
19
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
20
+
21
+ const { command, args } = await resolveArtilleryCommand(rootPath, configPath, outputPath);
22
+ const timeout = Math.max(90_000, ((options.duration || 30) + 60) * 1000);
23
+
24
+ try {
25
+ const result = await execFileAsync(command, args, {
26
+ cwd: rootPath,
27
+ timeout,
28
+ maxBuffer: 10 * 1024 * 1024
29
+ });
30
+ return {
31
+ ok: true,
32
+ command,
33
+ args,
34
+ stdout: result.stdout || "",
35
+ stderr: result.stderr || "",
36
+ exitCode: 0,
37
+ report: await readJsonFile(outputPath)
38
+ };
39
+ } catch (error) {
40
+ return {
41
+ ok: false,
42
+ command,
43
+ args,
44
+ stdout: error?.stdout || "",
45
+ stderr: error?.stderr || error?.message || "",
46
+ exitCode: error?.code ?? null,
47
+ signal: error?.signal,
48
+ report: await readJsonFile(outputPath)
49
+ };
50
+ }
51
+ }
52
+
53
+ async function resolveArtilleryCommand(rootPath, configPath, outputPath) {
54
+ const binaryName = process.platform === "win32" ? "artillery.cmd" : "artillery";
55
+ const toolBin = path.join(TOOL_ROOT, "node_modules", ".bin", binaryName);
56
+ if (await fileExists(toolBin)) {
57
+ return {
58
+ command: toolBin,
59
+ args: ["run", configPath, "--output", outputPath]
60
+ };
61
+ }
62
+
63
+ const projectBin = path.join(rootPath, "node_modules", ".bin", binaryName);
64
+ if (await fileExists(projectBin)) {
65
+ return {
66
+ command: projectBin,
67
+ args: ["run", configPath, "--output", outputPath]
68
+ };
69
+ }
70
+
71
+ return {
72
+ command: process.platform === "win32" ? "npm.cmd" : "npm",
73
+ args: ["exec", "--yes", "artillery", "--", "run", configPath, "--output", outputPath]
74
+ };
75
+ }
76
+
77
+ async function readJsonFile(filePath) {
78
+ try {
79
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
@@ -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,100 @@
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 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");
23
+
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");
27
+
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
+ }
34
+
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
+ };
45
+ }
46
+
47
+ export function isLocalTarget(target) {
48
+ let parsed;
49
+ try {
50
+ parsed = new URL(target);
51
+ } catch {
52
+ return false;
53
+ }
54
+
55
+ return LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
56
+ }
57
+
58
+ 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
+ }
65
+
66
+ if (!["http:", "https:"].includes(parsed.protocol)) {
67
+ throw new Error("Stress target must use http:// or https://.");
68
+ }
69
+
70
+ return parsed.toString().replace(/\/$/, "");
71
+ }
72
+
73
+ function getExplicitTargetPath(parsed) {
74
+ const pathname = parsed.pathname.replace(/\/$/, "") || "/";
75
+ if (pathname === "/" && !parsed.search) return null;
76
+ return `${pathname}${parsed.search}`;
77
+ }
78
+
79
+ 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;
86
+ }
87
+
88
+ 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;
94
+ }
95
+
96
+ function assertWithinLimit(value, limit, label, unit) {
97
+ if (value > limit) {
98
+ throw new Error(`Refusing --${label} ${value}. Maximum allowed is ${limit} ${unit}.`);
99
+ }
100
+ }