itworksbut 0.7.0 → 0.7.2

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
@@ -270,6 +270,6 @@ Each check is a plain ESM module with an `id`, metadata, and async `run(context)
270
270
 
271
271
  ## What It Does Not Guarantee
272
272
 
273
- ItWorksBut is a static heuristic scanner, not a pentest, SAST replacement, dependency vulnerability database, or runtime security monitor. Findings intentionally use wording such as "possible", "potential", and "appears to" when a check is heuristic.
273
+ ItWorksBut is a static heuristic scanner. Findings intentionally use wording such as "possible", "potential", and "appears to" when a check is heuristic.
274
274
 
275
275
  Use it as a CI guardrail for common project hygiene and security mistakes. For production systems, combine it with code review, tests, dependency scanning, secrets scanning, threat modeling, and focused security assessment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "itworksbut",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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,7 +36,10 @@
36
36
  ],
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
- "artillery": "^2.0.30",
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",
40
43
  "boxen": "^8.0.1",
41
44
  "chalk": "^5.6.2",
42
45
  "cli-table3": "^0.6.5",
@@ -1,95 +1,109 @@
1
- import boxen from "boxen";
2
- import chalk, { Chalk } from "chalk";
3
- import figlet from "figlet";
4
- import gradient from "gradient-string";
5
- import ora from "ora";
1
+ import boxen from 'boxen';
2
+ import chalk, { Chalk } from 'chalk';
3
+ import figlet from 'figlet';
4
+ import gradient from 'gradient-string';
5
+ import ora from 'ora';
6
6
 
7
7
  const SPINNER_TEXT = {
8
- git: "Checking git hygiene",
9
- env: "Sniffing for secrets",
10
- dependencies: "Interrogating package.json",
11
- ci: "Inspecting CI rituals",
12
- node: "Poking the Node.js backend",
13
- web: "Looking for frontend footguns",
14
- api: "Testing the API trust issues",
15
- database: "Watching SQL strings misbehave",
16
- electron: "Opening the Electron danger drawer",
17
- tauri: "Reading Tauri permissions",
18
- default: "Looking for things that work but should not ship"
8
+ git: 'Checking git hygiene',
9
+ env: 'Sniffing for secrets',
10
+ dependencies: 'Interrogating package.json',
11
+ ci: 'Inspecting CI rituals',
12
+ node: 'Poking the Node.js backend',
13
+ web: 'Looking for frontend footguns',
14
+ api: 'Testing the API trust issues',
15
+ database: 'Watching SQL strings misbehave',
16
+ electron: 'Opening the Electron danger drawer',
17
+ tauri: 'Reading Tauri permissions',
18
+ stress: 'Running controlled API stress test',
19
+ default: 'Looking for things that work but should not ship',
19
20
  };
20
21
 
21
22
  export function isFancyOutputEnabled(options = {}, env = process.env, stdout = process.stdout) {
22
- return Boolean(stdout.isTTY) && !env.CI && !options.json && !options.sarif && !options.todo && !options.noColor;
23
+ return Boolean(stdout.isTTY) && !env.CI && !options.json && !options.sarif && !options.todo && !options.noColor;
23
24
  }
24
25
 
25
26
  export function isColorEnabled(options = {}, env = process.env, stdout = process.stdout) {
26
- if (options.noColor || options.json || options.sarif || options.todo) return false;
27
- if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return true;
28
- if (env.CI) return false;
29
- return Boolean(stdout.isTTY);
27
+ if (options.noColor || options.json || options.sarif || options.todo) return false;
28
+ if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true;
29
+ if (env.CI) return false;
30
+ return Boolean(stdout.isTTY);
30
31
  }
31
32
 
32
33
  export function getChalk(options = {}) {
33
- if (!isColorEnabled(options)) return new Chalk({ level: 0 });
34
- return chalk;
34
+ if (!isColorEnabled(options)) return new Chalk({ level: 0 });
35
+ return chalk;
35
36
  }
36
37
 
37
38
  export function shouldUseSpinner(options = {}, env = process.env, stdout = process.stdout) {
38
- return (
39
- Boolean(stdout.isTTY) &&
40
- !env.CI &&
41
- !options.json &&
42
- !options.sarif &&
43
- !options.todo &&
44
- !options.quiet
45
- );
39
+ return Boolean(stdout.isTTY) && !env.CI && !options.json && !options.sarif && !options.todo && !options.quiet;
46
40
  }
47
41
 
48
42
  export function createScanSpinner(options = {}) {
49
- if (!shouldUseSpinner(options)) return null;
50
- return ora({
51
- text: SPINNER_TEXT.default,
52
- stream: process.stderr,
53
- color: "cyan"
54
- });
43
+ if (!shouldUseSpinner(options)) return null;
44
+ return ora({
45
+ text: SPINNER_TEXT.default,
46
+ stream: process.stderr,
47
+ color: 'cyan',
48
+ });
49
+ }
50
+
51
+ export function createStressSpinner(options = {}) {
52
+ if (!shouldUseSpinner(options)) return null;
53
+ return ora({
54
+ text: SPINNER_TEXT.stress,
55
+ stream: process.stderr,
56
+ color: 'cyan',
57
+ });
55
58
  }
56
59
 
57
60
  export function printIntro(options = {}) {
58
- if (options.json || options.sarif || options.todo || options.noBanner || options.quiet || process.env.CI || !process.stdout.isTTY) {
59
- return;
60
- }
61
+ if (
62
+ options.json ||
63
+ options.sarif ||
64
+ options.todo ||
65
+ options.noBanner ||
66
+ options.quiet ||
67
+ process.env.CI ||
68
+ !process.stdout.isTTY
69
+ ) {
70
+ return;
71
+ }
61
72
 
62
- const colors = getChalk(options);
63
- const title = renderTitle(options.noColor);
64
- const claim = `${colors.bold("AI-built? Nice.")}\n${colors.yellow("Now let's see what breaks before production.")}`;
73
+ const colors = getChalk(options);
74
+ const stress = options.command === 'stress';
75
+ const title = renderTitle('ItWorksBut', options.noColor);
76
+ const claim = stress
77
+ ? `${colors.bold('Controlled API stress test.')}\n${colors.yellow('Only run this against systems you own or are authorized to test.')}`
78
+ : `${colors.bold('AI-built? Nice.')}\n${colors.yellow("Now let's see what breaks before production.")}`;
65
79
 
66
- process.stdout.write(`${title}\n`);
67
- process.stdout.write(
68
- `${boxen(claim, {
69
- padding: 1,
70
- margin: 1,
71
- borderStyle: "round",
72
- borderColor: options.noColor ? undefined : "cyan"
73
- })}\n`
74
- );
80
+ process.stdout.write(`${title}\n`);
81
+ process.stdout.write(
82
+ `${boxen(claim, {
83
+ padding: 1,
84
+ margin: 1,
85
+ borderStyle: 'round',
86
+ borderColor: options.noColor ? undefined : 'cyan',
87
+ })}\n`,
88
+ );
75
89
  }
76
90
 
77
- function renderTitle(noColor) {
78
- let title = "ItWorksBut";
79
- try {
80
- title = figlet.textSync("ItWorksBut", {
81
- font: "ANSI Shadow",
82
- horizontalLayout: "default",
83
- verticalLayout: "default"
84
- });
85
- } catch {
86
- title = "ItWorksBut";
87
- }
91
+ function renderTitle(value, noColor) {
92
+ let title = value;
93
+ try {
94
+ title = figlet.textSync(value, {
95
+ font: 'ANSI Shadow',
96
+ horizontalLayout: 'default',
97
+ verticalLayout: 'default',
98
+ });
99
+ } catch {
100
+ title = 'ItWorksBut';
101
+ }
88
102
 
89
- try {
90
- if (noColor) return title;
91
- return gradient.rainbow(title);
92
- } catch {
93
- return title;
94
- }
103
+ try {
104
+ if (noColor) return title;
105
+ return gradient.rainbow(title);
106
+ } catch {
107
+ return title;
108
+ }
95
109
  }
@@ -6,16 +6,31 @@ import { runArtillery } from "../stress/artilleryRunner.js";
6
6
  import { parseArtilleryResult } from "../stress/stressResultParser.js";
7
7
  import { reportStressConsole } from "../stress/stressRenderer.js";
8
8
  import { writeStressMarkdownReport } from "../reporters/stressMarkdownReport.js";
9
+ import { createStressSpinner, printIntro } from "../cli/terminal.js";
9
10
 
10
11
  export async function runStressCommand(args, options = {}) {
11
12
  if (args.todo || args.sarif) {
12
13
  throw new Error("The stress command supports console output, --json, and --report.");
13
14
  }
14
15
 
15
- const result = await runStress({
16
+ const stressOptions = {
16
17
  rootPath: path.resolve(args.path || "."),
17
18
  ...validateStressOptions(args)
18
- }, options);
19
+ };
20
+
21
+ printIntro(args);
22
+
23
+ const spinner = createStressSpinner(args);
24
+ if (spinner) spinner.start();
25
+
26
+ let result;
27
+ try {
28
+ result = await runStress(stressOptions, options);
29
+ if (spinner) spinner.succeed("Stress test complete. Now the pressure readings.");
30
+ } catch (error) {
31
+ if (spinner) spinner.fail("Stress test stopped before results were printed.");
32
+ throw error;
33
+ }
19
34
 
20
35
  if (args.json) {
21
36
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -36,9 +51,10 @@ export async function runStressCommand(args, options = {}) {
36
51
 
37
52
  export async function runStress(options, runnerOptions = {}) {
38
53
  const startedAt = new Date();
39
- const discovery = await discoverEndpoints(options.rootPath);
54
+ const discovery = withExplicitTargetEndpoint(await discoverEndpoints(options.rootPath), options.targetPath);
40
55
  const baseDetails = {
41
56
  target: options.target,
57
+ artilleryTarget: options.artilleryTarget || options.target,
42
58
  duration: options.duration,
43
59
  arrivalRate: options.arrivalRate,
44
60
  maxVusers: options.maxVusers,
@@ -77,7 +93,7 @@ export async function runStress(options, runnerOptions = {}) {
77
93
  }
78
94
 
79
95
  const config = createArtilleryConfig({
80
- target: options.target,
96
+ target: options.artilleryTarget || options.target,
81
97
  duration: options.duration,
82
98
  arrivalRate: options.arrivalRate,
83
99
  maxVusers: options.maxVusers,
@@ -112,3 +128,30 @@ function stressResult(result) {
112
128
  ...result
113
129
  };
114
130
  }
131
+
132
+ function withExplicitTargetEndpoint(discovery, targetPath) {
133
+ if (!targetPath) return discovery;
134
+ if (discovery.endpoints.some((endpoint) => endpoint.method === "GET" && endpoint.path === targetPath)) {
135
+ return discovery;
136
+ }
137
+
138
+ const explicitEndpoint = {
139
+ method: "GET",
140
+ path: targetPath,
141
+ source: "--target",
142
+ type: "explicit-target",
143
+ dynamic: false,
144
+ status: "selected"
145
+ };
146
+
147
+ return {
148
+ status: "pass",
149
+ endpoints: sortEndpoints([...discovery.endpoints, explicitEndpoint]),
150
+ safeEndpoints: sortEndpoints([...discovery.safeEndpoints, explicitEndpoint]),
151
+ skippedEndpoints: discovery.skippedEndpoints
152
+ };
153
+ }
154
+
155
+ function sortEndpoints(endpoints) {
156
+ return [...endpoints].sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`));
157
+ }
@@ -2,10 +2,11 @@ import path from "node:path";
2
2
  import { DEFAULT_IGNORE } from "../core/config.js";
3
3
  import { walkProject } from "../core/fileWalker.js";
4
4
  import { readFileSafe } from "../utils/fs.js";
5
+ import { isServerOrApiFile } from "../checks/helpers.js";
5
6
 
6
- const HTTP_METHODS = ["get", "head", "post", "put", "patch", "delete"];
7
+ const HTTP_METHODS = ["get", "head", "post", "put", "patch", "delete", "options"];
7
8
  const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
8
- const ROUTE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
9
+ const ROUTE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
9
10
  const DISCOVERY_IGNORE = [
10
11
  ...DEFAULT_IGNORE,
11
12
  "test/**",
@@ -28,15 +29,23 @@ export async function discoverEndpoints(rootPath) {
28
29
 
29
30
  export async function discoverEndpointsFromFiles({ rootPath, files, readFile }) {
30
31
  const endpoints = [];
32
+ const fileContents = [];
31
33
 
32
34
  for (const file of files.filter(isSourceFile)) {
33
35
  const content = await readFile(file);
34
36
  if (content === null || content === undefined) continue;
37
+ fileContents.push({ file, content });
38
+ }
39
+
40
+ const mountPrefixes = collectMountPrefixes(fileContents);
35
41
 
42
+ for (const { file, content } of fileContents) {
36
43
  endpoints.push(...discoverExpressEndpoints(file, content));
44
+ endpoints.push(...discoverMountedRouterEndpoints(file, content, mountPrefixes));
45
+ endpoints.push(...discoverFastifyRouteObjects(file, content));
37
46
  endpoints.push(...discoverFetchReferences(file, content));
38
- endpoints.push(...discoverNextAppRouterEndpoints(file, content));
39
- endpoints.push(...discoverNextPagesRouterEndpoints(file, content));
47
+ endpoints.push(...discoverFileConventionEndpoints(file, content));
48
+ endpoints.push(...discoverApiCandidateFileEndpoints(file, content));
40
49
  }
41
50
 
42
51
  return classifyEndpoints(dedupeEndpoints(endpoints), rootPath);
@@ -50,7 +59,10 @@ export function classifyEndpoints(endpoints) {
50
59
  let status = "selected";
51
60
  let reason;
52
61
 
53
- if (unsafe) {
62
+ if (method === "OPTIONS") {
63
+ status = "skipped";
64
+ reason = "unsupported method";
65
+ } else if (unsafe) {
54
66
  status = "skipped";
55
67
  reason = "unsafe method";
56
68
  } else if (dynamic) {
@@ -80,7 +92,7 @@ export function classifyEndpoints(endpoints) {
80
92
  function discoverExpressEndpoints(file, content) {
81
93
  const endpoints = [];
82
94
  const methods = HTTP_METHODS.join("|");
83
- const regex = new RegExp(`\\b(?:app|router|server)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
95
+ const regex = new RegExp(`\\b[A-Za-z_$][\\w$]*\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
84
96
  let match;
85
97
 
86
98
  while ((match = regex.exec(content)) !== null) {
@@ -92,6 +104,43 @@ function discoverExpressEndpoints(file, content) {
92
104
  return endpoints;
93
105
  }
94
106
 
107
+ function discoverMountedRouterEndpoints(file, content, mountPrefixes) {
108
+ if (mountPrefixes.length === 0) return [];
109
+
110
+ const endpoints = [];
111
+ const methods = HTTP_METHODS.join("|");
112
+ const regex = new RegExp(`\\b(?:router|Router|apiRouter|routes|route)\\s*\\.\\s*(${methods})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, "gi");
113
+ let match;
114
+
115
+ while ((match = regex.exec(content)) !== null) {
116
+ const routePath = normalizeRoutePath(match[3]);
117
+ if (!routePath || routePath.startsWith("/api")) continue;
118
+ for (const prefix of mountPrefixes) {
119
+ endpoints.push(endpoint(match[1], joinRoutePath(prefix, routePath), file, "mounted-router"));
120
+ }
121
+ }
122
+
123
+ return endpoints;
124
+ }
125
+
126
+ function discoverFastifyRouteObjects(file, content) {
127
+ const endpoints = [];
128
+ const regex = /\bfastify\s*\.\s*route\s*\(\s*\{([\s\S]*?)\}\s*\)/gi;
129
+ let match;
130
+
131
+ while ((match = regex.exec(content)) !== null) {
132
+ const objectText = match[1];
133
+ const methodMatch = objectText.match(/\bmethod\s*:\s*(['"`])(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\1/i);
134
+ const urlMatch = objectText.match(/\b(?:url|path)\s*:\s*(['"`])([^'"`]+)\1/i);
135
+ if (!methodMatch || !urlMatch) continue;
136
+ const routePath = normalizeRoutePath(urlMatch[2]);
137
+ if (!routePath) continue;
138
+ endpoints.push(endpoint(methodMatch[2], routePath, file, "fastify-route-object"));
139
+ }
140
+
141
+ return endpoints;
142
+ }
143
+
95
144
  function discoverFetchReferences(file, content) {
96
145
  const endpoints = [];
97
146
  const regex = /\bfetch\s*\(\s*(['"`])(\/api\/[^'"`]+)\1/gi;
@@ -106,12 +155,22 @@ function discoverFetchReferences(file, content) {
106
155
  return endpoints;
107
156
  }
108
157
 
158
+ function discoverFileConventionEndpoints(file, content) {
159
+ return [
160
+ ...discoverNextAppRouterEndpoints(file, content),
161
+ ...discoverNextPagesRouterEndpoints(file, content),
162
+ ...discoverSvelteKitEndpoints(file, content),
163
+ ...discoverNitroEndpoints(file, content),
164
+ ...discoverAstroApiEndpoints(file, content)
165
+ ];
166
+ }
167
+
109
168
  function discoverNextAppRouterEndpoints(file, content) {
110
169
  const normalized = normalizeFilePath(file);
111
- const match = normalized.match(/(?:^|\/)(?:src\/)?app\/api\/(.+)\/route\.(?:js|jsx|ts|tsx|mjs|cjs)$/);
170
+ const match = normalized.match(/(?:^|\/)(?:src\/)?app\/(.+)\/route\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/);
112
171
  if (!match) return [];
113
172
 
114
- const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
173
+ const routePath = normalizeRoutePath(`/${routeSegmentsToPath(match[1])}`);
115
174
  const exportedMethods = discoverExportedRouteMethods(content);
116
175
  const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
117
176
 
@@ -120,7 +179,7 @@ function discoverNextAppRouterEndpoints(file, content) {
120
179
 
121
180
  function discoverNextPagesRouterEndpoints(file, content) {
122
181
  const normalized = normalizeFilePath(file);
123
- const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|jsx|ts|tsx|mjs|cjs)$/);
182
+ const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/);
124
183
  if (!match) return [];
125
184
 
126
185
  const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
@@ -130,9 +189,58 @@ function discoverNextPagesRouterEndpoints(file, content) {
130
189
  return methods.map((method) => endpoint(method, routePath, file, "next-pages-router"));
131
190
  }
132
191
 
192
+ function discoverSvelteKitEndpoints(file, content) {
193
+ const normalized = normalizeFilePath(file);
194
+ const match = normalized.match(/(?:^|\/)(?:src\/)?routes\/(.+)\/\+server\.(?:js|ts|mjs|cjs|mts|cts)$/);
195
+ if (!match) return [];
196
+
197
+ const routePath = normalizeRoutePath(`/${routeSegmentsToPath(match[1])}`);
198
+ const exportedMethods = discoverExportedRouteMethods(content);
199
+ const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
200
+
201
+ return methods.map((method) => endpoint(method, routePath, file, "sveltekit-server-route"));
202
+ }
203
+
204
+ function discoverNitroEndpoints(file, content) {
205
+ const normalized = normalizeFilePath(file);
206
+ const match = normalized.match(/(?:^|\/)(?:src\/)?server\/api\/(.+?)(?:\.(get|head|post|put|patch|delete|options))?\.(?:js|ts|mjs|cjs|mts|cts)$/i);
207
+ if (!match) return [];
208
+
209
+ const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
210
+ const methods = match[2] ? [match[2].toUpperCase()] : discoverMethodGuards(content);
211
+
212
+ return (methods.length > 0 ? methods : ["GET"]).map((method) => endpoint(method, routePath, file, "nitro-server-api"));
213
+ }
214
+
215
+ function discoverAstroApiEndpoints(file, content) {
216
+ const normalized = normalizeFilePath(file);
217
+ const match = normalized.match(/(?:^|\/)(?:src\/)?pages\/api\/(.+)\.(?:js|ts|mjs|cjs|mts|cts)$/);
218
+ if (!match) return [];
219
+
220
+ const routePath = normalizeRoutePath(`/api/${routeSegmentsToPath(match[1])}`);
221
+ const exportedMethods = discoverExportedRouteMethods(content);
222
+ const methods = exportedMethods.length > 0 ? exportedMethods : ["GET"];
223
+
224
+ return methods.map((method) => endpoint(method, routePath, file, "astro-api-route"));
225
+ }
226
+
227
+ function discoverApiCandidateFileEndpoints(file, content) {
228
+ if (!isSastApiCandidate(file, content)) return [];
229
+ if (hasExplicitRouteDeclaration(content) || isFileConventionRoute(file)) return [];
230
+
231
+ const routePath = inferRoutePathFromFile(file);
232
+ if (!routePath) return [];
233
+
234
+ const methods = discoverExportedRouteMethods(content);
235
+ const guardedMethods = discoverMethodGuards(content);
236
+ const inferredMethods = methods.length > 0 ? methods : guardedMethods;
237
+
238
+ return (inferredMethods.length > 0 ? inferredMethods : ["GET"]).map((method) => endpoint(method, routePath, file, "sast-api-candidate"));
239
+ }
240
+
133
241
  function discoverExportedRouteMethods(content) {
134
242
  const methods = new Set();
135
- const regex = /\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE)\b/g;
243
+ const regex = /\bexport\s+(?:async\s+)?function\s+(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\b/g;
136
244
  let match;
137
245
 
138
246
  while ((match = regex.exec(content)) !== null) {
@@ -144,7 +252,7 @@ function discoverExportedRouteMethods(content) {
144
252
 
145
253
  function discoverMethodGuards(content) {
146
254
  const methods = new Set();
147
- const regex = /\b(?:req|request)\.method\s*(?:===|!==|==|!=)\s*['"`](GET|HEAD|POST|PUT|PATCH|DELETE)['"`]/gi;
255
+ const regex = /\b(?:req|request)\.method\s*(?:===|!==|==|!=)\s*['"`](GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)['"`]/gi;
148
256
  let match;
149
257
 
150
258
  while ((match = regex.exec(content)) !== null) {
@@ -154,6 +262,25 @@ function discoverMethodGuards(content) {
154
262
  return [...methods];
155
263
  }
156
264
 
265
+ 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
+ }
278
+ }
279
+ }
280
+
281
+ return [...prefixes].sort();
282
+ }
283
+
157
284
  function endpoint(method, routePath, file, type) {
158
285
  return {
159
286
  method: method.toUpperCase(),
@@ -177,6 +304,55 @@ function isSourceFile(file) {
177
304
  return ROUTE_EXTENSIONS.has(path.extname(normalized));
178
305
  }
179
306
 
307
+ function isSastApiCandidate(file, content) {
308
+ const normalized = normalizeFilePath(file);
309
+ return (
310
+ isServerOrApiFile(normalized) ||
311
+ normalized.startsWith("api/") ||
312
+ normalized.startsWith("routes/") ||
313
+ normalized.startsWith("handlers/") ||
314
+ normalized.startsWith("controllers/") ||
315
+ normalized.includes("/handlers/") ||
316
+ normalized.includes("/controllers/")
317
+ ) && /\b(?:req|request|res|response|ctx|context|NextRequest|NextResponse|Response|json)\b/.test(content);
318
+ }
319
+
320
+ function hasExplicitRouteDeclaration(content) {
321
+ const methods = HTTP_METHODS.join("|");
322
+ return new RegExp(`\\b[A-Za-z_$][\\w$]*\\s*\\.\\s*(?:${methods}|route)\\s*\\(`, "i").test(content);
323
+ }
324
+
325
+ function isFileConventionRoute(file) {
326
+ const normalized = normalizeFilePath(file);
327
+ return (
328
+ /(?:^|\/)(?:src\/)?app\/.+\/route\./.test(normalized) ||
329
+ /(?:^|\/)(?:src\/)?pages\/api\/.+\./.test(normalized) ||
330
+ /(?:^|\/)(?:src\/)?routes\/.+\/\+server\./.test(normalized) ||
331
+ /(?:^|\/)(?:src\/)?server\/api\/.+\./.test(normalized)
332
+ );
333
+ }
334
+
335
+ function inferRoutePathFromFile(file) {
336
+ const normalized = normalizeFilePath(file);
337
+ const withoutExtension = normalized.replace(/\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/, "");
338
+ const patterns = [
339
+ { regex: /(?:^|\/)(?:src\/)?api\/(.+)$/, prefix: "/api/" },
340
+ { regex: /(?:^|\/)(?:src\/)?server\/api\/(.+)$/, prefix: "/api/" },
341
+ { regex: /(?:^|\/)(?:src\/)?routes\/(.+)$/, prefix: "/" },
342
+ { regex: /(?:^|\/)(?:src\/)?server\/routes\/(.+)$/, prefix: "/" },
343
+ { regex: /(?:^|\/)(?:src\/)?handlers\/(.+)$/, prefix: "/" },
344
+ { regex: /(?:^|\/)(?:src\/)?controllers\/(.+)$/, prefix: "/" }
345
+ ];
346
+
347
+ for (const { regex, prefix } of patterns) {
348
+ const match = withoutExtension.match(regex);
349
+ if (!match) continue;
350
+ return normalizeRoutePath(`${prefix}${routeSegmentsToPath(match[1])}`);
351
+ }
352
+
353
+ return null;
354
+ }
355
+
180
356
  function normalizeFilePath(file) {
181
357
  return String(file).replace(/\\/g, "/");
182
358
  }
@@ -187,11 +363,17 @@ function normalizeRoutePath(value) {
187
363
  return pathValue.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
188
364
  }
189
365
 
366
+ function joinRoutePath(prefix, routePath) {
367
+ return normalizeRoutePath(`${prefix}/${routePath.replace(/^\//, "")}`);
368
+ }
369
+
190
370
  function routeSegmentsToPath(value) {
191
371
  return value
192
372
  .split("/")
193
373
  .filter(Boolean)
194
- .map((segment) => segment.replace(/^\[(\.\.\.)?(.+)]$/, ":$2"))
374
+ .filter((segment) => !segment.startsWith("(") && !segment.startsWith("@") && !segment.startsWith("_"))
375
+ .map((segment) => segment.replace(/^\[\[?\.\.\.(.+)]]?$/, ":$1").replace(/^\[(.+)]$/, ":$1"))
376
+ .filter((segment) => segment !== "index")
195
377
  .join("/");
196
378
  }
197
379
 
@@ -1,80 +1,94 @@
1
- import { getChalk } from "../cli/terminal.js";
1
+ import { getChalk, isFancyOutputEnabled } from '../cli/terminal.js';
2
2
 
3
3
  export function reportStressConsole(result, options = {}) {
4
- const colors = getChalk(options);
5
- const details = result.details || {};
4
+ const colors = getChalk(options);
5
+ const details = result.details || {};
6
+ const rich = isFancyOutputEnabled(options);
6
7
 
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`);
8
+ if (!options.quiet) {
9
+ if (!rich) process.stdout.write(`${colors.bold('ItWorksBut Stress')}\n\n`);
10
+ process.stdout.write('Only run this against systems you own or are explicitly authorized to test.\n\n');
11
+ process.stdout.write(`Target: ${details.target}\n`);
12
+ process.stdout.write(`Duration: ${details.duration}s\n`);
13
+ process.stdout.write(`Arrival rate: ${details.arrivalRate} req/s\n`);
14
+ process.stdout.write(`Max virtual users: ${details.maxVusers}\n\n`);
14
15
 
15
- writeDiscovery(details, colors);
16
- writeEndpoints(details, colors);
17
- }
16
+ writeDiscovery(details, colors);
17
+ writeEndpoints(details, colors);
18
+ }
18
19
 
19
- writeSummary(result, colors);
20
+ writeSummary(result, colors);
20
21
  }
21
22
 
22
23
  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;
24
+ const found = details.endpointsFound || 0;
25
+ const safe = details.safeEndpoints || 0;
26
+ const skipped = details.skippedEndpoints?.length || 0;
27
+ const unsafe = details.skippedEndpoints?.filter(endpoint => endpoint.reason === 'unsafe method').length || 0;
28
+ const dynamic =
29
+ details.skippedEndpoints?.filter(endpoint => endpoint.reason === 'dynamic route requires parameter').length ||
30
+ 0;
28
31
 
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");
32
+ process.stdout.write(
33
+ `${colors.green('')} Endpoint discovery: ${found} ${found === 1 ? 'endpoint' : 'endpoints'} found\n`,
34
+ );
35
+ process.stdout.write(
36
+ `${colors.green('✓')} Safe endpoints: ${safe} GET/HEAD ${safe === 1 ? 'endpoint' : 'endpoints'} selected\n`,
37
+ );
38
+ if (skipped > 0) {
39
+ process.stdout.write(`- Skipped: ${unsafe} unsafe methods, ${dynamic} dynamic routes\n`);
40
+ }
41
+ process.stdout.write('\n');
35
42
  }
36
43
 
37
44
  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
- }
45
+ const testedEndpoints = details.testedEndpoints || [];
46
+ if (testedEndpoints.length === 0) {
47
+ process.stdout.write('Running Artillery: skipped\n\n');
48
+ return;
49
+ }
43
50
 
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
- }
51
+ process.stdout.write('Running Artillery: complete\n\n');
52
+ for (const endpoint of testedEndpoints) {
53
+ const symbol =
54
+ endpoint.status === 'pass'
55
+ ? colors.green('✓')
56
+ : endpoint.status === 'warn'
57
+ ? colors.yellow('⚠')
58
+ : colors.red('✕');
59
+ process.stdout.write(`${symbol} ${endpoint.method} ${endpoint.path}\n`);
60
+ process.stdout.write(` p95: ${formatMs(endpoint.p95)}\n`);
61
+ process.stdout.write(` p99: ${formatMs(endpoint.p99)}\n`);
62
+ process.stdout.write(` errors: ${endpoint.errors}\n`);
63
+ process.stdout.write(` error rate: ${formatPercent(endpoint.errorRate)}\n\n`);
64
+ }
53
65
  }
54
66
 
55
67
  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;
68
+ const details = result.details || {};
69
+ const warnings = details.warnings || 0;
70
+ const failed = details.failed || 0;
71
+ const tested = details.testedEndpoints?.length || 0;
72
+ const skipped = details.skippedEndpoints?.length || 0;
61
73
 
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
- }
74
+ process.stdout.write('Summary:\n');
75
+ process.stdout.write(`${colors.green('')} Tested endpoints: ${tested}\n`);
76
+ process.stdout.write(`${colors.yellow('')} Warnings: ${warnings}\n`);
77
+ process.stdout.write(`${colors.red('')} Failed: ${failed}\n`);
78
+ process.stdout.write(`- Skipped: ${skipped}\n`);
79
+ process.stdout.write(`- Status: ${result.status}\n`);
80
+ process.stdout.write(`- ${result.summary}\n`);
81
+ if (details.artilleryError) {
82
+ process.stdout.write(`- Artillery error: ${details.artilleryError}\n`);
83
+ }
72
84
  }
73
85
 
74
86
  function formatMs(value) {
75
- return value === null || value === undefined ? "n/a" : `${Math.round(value)} ms`;
87
+ return value === null || value === undefined ? 'n/a' : `${Math.round(value)} ms`;
76
88
  }
77
89
 
78
90
  function formatPercent(value) {
79
- return `${Number(value || 0).toFixed(Number.isInteger(value) ? 0 : 2).replace(/\.00$/, "")}%`;
91
+ return `${Number(value || 0)
92
+ .toFixed(Number.isInteger(value) ? 0 : 2)
93
+ .replace(/\.00$/, '')}%`;
80
94
  }
@@ -15,6 +15,8 @@ export const STRESS_LIMITS = {
15
15
 
16
16
  export function validateStressOptions(args = {}) {
17
17
  const target = normalizeTarget(args.target || STRESS_DEFAULTS.target);
18
+ const parsedTarget = new URL(target);
19
+ const targetPath = getExplicitTargetPath(parsedTarget);
18
20
  const duration = parsePositiveNumber(args.duration, STRESS_DEFAULTS.duration, "duration");
19
21
  const arrivalRate = parsePositiveNumber(args.arrivalRate, STRESS_DEFAULTS.arrivalRate, "arrival-rate");
20
22
  const maxVusers = parsePositiveInteger(args.maxVusers, STRESS_DEFAULTS.maxVusers, "max-vusers");
@@ -32,6 +34,8 @@ export function validateStressOptions(args = {}) {
32
34
 
33
35
  return {
34
36
  target,
37
+ artilleryTarget: targetPath ? parsedTarget.origin : target,
38
+ targetPath,
35
39
  duration,
36
40
  arrivalRate,
37
41
  maxVusers,
@@ -66,6 +70,12 @@ function normalizeTarget(value) {
66
70
  return parsed.toString().replace(/\/$/, "");
67
71
  }
68
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
+
69
79
  function parsePositiveNumber(value, fallback, label) {
70
80
  if (value === undefined || value === null || value === "") return fallback;
71
81
  const number = Number(value);