scanwarp 0.2.0 → 0.3.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/dist/commands/dev-mcp.js +8 -0
- package/dist/commands/dev.js +1054 -0
- package/dist/commands/init.js +27 -5
- package/dist/commands/mcp.js +380 -0
- package/dist/commands/server.js +102 -0
- package/dist/dev/analysis-engine.js +145 -0
- package/dist/dev/analyzers/schema-drift.js +230 -0
- package/dist/dev/analyzers.js +230 -0
- package/dist/dev/mcp-dev.js +290 -0
- package/dist/index.js +141 -3
- package/dist/integrations/instrument.js +236 -0
- package/dist/mcp/api.js +86 -0
- package/dist/mcp/index.js +403 -0
- package/dist/mcp/tools.js +487 -0
- package/package.json +60 -57
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.devCommand = devCommand;
|
|
7
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
+
const ora_1 = __importDefault(require("ora"));
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const http_1 = require("http");
|
|
13
|
+
const chokidar_1 = require("chokidar");
|
|
14
|
+
const detector_js_1 = require("../detector.js");
|
|
15
|
+
const analysis_engine_js_1 = require("../dev/analysis-engine.js");
|
|
16
|
+
const schema_drift_js_1 = require("../dev/analyzers/schema-drift.js");
|
|
17
|
+
// ─── Main dev command ───
|
|
18
|
+
// ─── Production setup detection ───
|
|
19
|
+
function checkProductionSetup(cwd) {
|
|
20
|
+
// Check if they've deployed or are using hosting platforms
|
|
21
|
+
const hasVercelConfig = fs_1.default.existsSync(path_1.default.join(cwd, '.vercel'));
|
|
22
|
+
const hasRailwayConfig = fs_1.default.existsSync(path_1.default.join(cwd, 'railway.json')) ||
|
|
23
|
+
fs_1.default.existsSync(path_1.default.join(cwd, 'railway.toml'));
|
|
24
|
+
const hasRenderConfig = fs_1.default.existsSync(path_1.default.join(cwd, 'render.yaml'));
|
|
25
|
+
const isUsingHosting = hasVercelConfig || hasRailwayConfig || hasRenderConfig;
|
|
26
|
+
if (!isUsingHosting) {
|
|
27
|
+
return true; // No hosting detected, so no need to warn
|
|
28
|
+
}
|
|
29
|
+
// Check if instrumentation is set up
|
|
30
|
+
const hasInstrumentationFile = fs_1.default.existsSync(path_1.default.join(cwd, 'instrumentation.ts')) ||
|
|
31
|
+
fs_1.default.existsSync(path_1.default.join(cwd, 'instrumentation.js'));
|
|
32
|
+
// Check if @scanwarp/instrument is in package.json
|
|
33
|
+
let hasInstrumentPackage = false;
|
|
34
|
+
const packageJsonPath = path_1.default.join(cwd, 'package.json');
|
|
35
|
+
if (fs_1.default.existsSync(packageJsonPath)) {
|
|
36
|
+
try {
|
|
37
|
+
const packageJson = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf-8'));
|
|
38
|
+
hasInstrumentPackage = !!(packageJson.dependencies?.['@scanwarp/instrument'] ||
|
|
39
|
+
packageJson.devDependencies?.['@scanwarp/instrument']);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Ignore parse errors
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return hasInstrumentationFile || hasInstrumentPackage;
|
|
46
|
+
}
|
|
47
|
+
async function devCommand(options = {}) {
|
|
48
|
+
const cwd = process.cwd();
|
|
49
|
+
console.log(chalk_1.default.bold.cyan('\n⚡ ScanWarp Dev Mode\n'));
|
|
50
|
+
// Step 1: Detect project
|
|
51
|
+
const spinner = (0, ora_1.default)('Detecting project...').start();
|
|
52
|
+
const detected = (0, detector_js_1.detectProject)(cwd);
|
|
53
|
+
spinner.succeed(`Detected: ${detected.framework || 'Node.js'}${detected.services.length > 0 ? ` + ${detected.services.join(', ')}` : ''}`);
|
|
54
|
+
// Check for production setup
|
|
55
|
+
const hasProductionSetup = checkProductionSetup(cwd);
|
|
56
|
+
if (!hasProductionSetup) {
|
|
57
|
+
console.log(chalk_1.default.yellow('\n⚠️ Production hosting detected but monitoring not configured!'));
|
|
58
|
+
console.log(chalk_1.default.gray(' Run `npx scanwarp init` to enable production monitoring.'));
|
|
59
|
+
console.log(chalk_1.default.gray(' This ensures you get alerts and AI diagnosis in production.\n'));
|
|
60
|
+
}
|
|
61
|
+
// Step 2: Determine the dev command
|
|
62
|
+
const devCmd = options.command || detectDevCommand(detected, cwd);
|
|
63
|
+
console.log(chalk_1.default.gray(` Dev command: ${devCmd}\n`));
|
|
64
|
+
// Step 3: Discover routes
|
|
65
|
+
const routeFileMap = {
|
|
66
|
+
fileToRoute: new Map(),
|
|
67
|
+
fileToType: new Map(),
|
|
68
|
+
};
|
|
69
|
+
const routes = discoverRoutes(detected, cwd, routeFileMap);
|
|
70
|
+
if (routes.pages.length > 0 || routes.apiRoutes.length > 0) {
|
|
71
|
+
console.log(chalk_1.default.green(` Found ${routes.pages.length} pages, ${routes.apiRoutes.length} API routes`));
|
|
72
|
+
}
|
|
73
|
+
// Step 4: Start in-memory ScanWarp server
|
|
74
|
+
const scanwarpPort = options.port || (await findAvailablePort(3456));
|
|
75
|
+
const { server: scanwarpServer, store } = await startLocalServer(scanwarpPort);
|
|
76
|
+
// Share route data with the store (for MCP API access)
|
|
77
|
+
store.routes = routes;
|
|
78
|
+
console.log(chalk_1.default.green(` ScanWarp local server: http://localhost:${scanwarpPort}\n`));
|
|
79
|
+
// Print MCP configuration instructions
|
|
80
|
+
console.log(chalk_1.default.bold(' MCP for AI coding tools:\n'));
|
|
81
|
+
console.log(chalk_1.default.gray(` Add to your MCP config (.cursor/mcp.json or claude_desktop_config.json):`));
|
|
82
|
+
console.log(chalk_1.default.gray(` {`));
|
|
83
|
+
console.log(chalk_1.default.gray(` "mcpServers": {`));
|
|
84
|
+
console.log(chalk_1.default.white(` "scanwarp-dev": {`));
|
|
85
|
+
console.log(chalk_1.default.white(` "command": "npx",`));
|
|
86
|
+
console.log(chalk_1.default.white(` "args": ["scanwarp", "dev-mcp", "--port", "${scanwarpPort}"]`));
|
|
87
|
+
console.log(chalk_1.default.white(` }`));
|
|
88
|
+
console.log(chalk_1.default.gray(` }`));
|
|
89
|
+
console.log(chalk_1.default.gray(` }\n`));
|
|
90
|
+
// Step 5: Start the user's dev server
|
|
91
|
+
const isNextJs = detected.framework === 'Next.js';
|
|
92
|
+
const devServerPort = detectDevServerPort(devCmd);
|
|
93
|
+
console.log(chalk_1.default.bold(` Starting: ${devCmd}\n`));
|
|
94
|
+
console.log(chalk_1.default.gray('─'.repeat(60)));
|
|
95
|
+
const child = startDevServer(devCmd, cwd, scanwarpPort, isNextJs);
|
|
96
|
+
// Use store's maps for tracking (shared with MCP API)
|
|
97
|
+
const previousResults = store.previousResults;
|
|
98
|
+
const baselines = store.baselines;
|
|
99
|
+
// eslint-disable-next-line prefer-const
|
|
100
|
+
let watcher;
|
|
101
|
+
// Handle cleanup
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
console.log(chalk_1.default.gray('\n\n Shutting down...'));
|
|
104
|
+
if (watcher) {
|
|
105
|
+
watcher.close();
|
|
106
|
+
}
|
|
107
|
+
if (child && !child.killed) {
|
|
108
|
+
child.kill('SIGTERM');
|
|
109
|
+
}
|
|
110
|
+
scanwarpServer.close();
|
|
111
|
+
// Print session summary
|
|
112
|
+
printSessionSummary(store);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
};
|
|
115
|
+
process.on('SIGINT', cleanup);
|
|
116
|
+
process.on('SIGTERM', cleanup);
|
|
117
|
+
child.on('exit', (code) => {
|
|
118
|
+
console.log(chalk_1.default.gray(`\n Dev server exited with code ${code}`));
|
|
119
|
+
if (watcher)
|
|
120
|
+
watcher.close();
|
|
121
|
+
scanwarpServer.close();
|
|
122
|
+
printSessionSummary(store);
|
|
123
|
+
process.exit(code || 0);
|
|
124
|
+
});
|
|
125
|
+
// Step 6: Wait for dev server to be ready, then crawl routes
|
|
126
|
+
if (devServerPort && (routes.pages.length > 0 || routes.apiRoutes.length > 0)) {
|
|
127
|
+
await waitForServer(devServerPort, 30_000).then(async (ready) => {
|
|
128
|
+
if (ready) {
|
|
129
|
+
console.log(chalk_1.default.gray('\n─'.repeat(60)));
|
|
130
|
+
console.log(chalk_1.default.bold.cyan('\n Initial route check\n'));
|
|
131
|
+
const initialResults = await crawlRoutes(routes, devServerPort, store.schemaTracker);
|
|
132
|
+
// Store initial results as both previous and baseline
|
|
133
|
+
for (const r of initialResults) {
|
|
134
|
+
previousResults.set(r.route, r);
|
|
135
|
+
if (r.status > 0 && r.status < 400) {
|
|
136
|
+
baselines.set(r.route, r.timeMs);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
console.log(chalk_1.default.gray('\n─'.repeat(60)));
|
|
140
|
+
console.log('');
|
|
141
|
+
}
|
|
142
|
+
}).catch(() => {
|
|
143
|
+
// Server didn't start in time — that's fine, skip crawl
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Step 7: Start file watcher
|
|
147
|
+
watcher = startFileWatcher(cwd, routes, routeFileMap, previousResults, baselines, store.schemaTracker, devServerPort);
|
|
148
|
+
// Enable live request log
|
|
149
|
+
store.liveLogEnabled = true;
|
|
150
|
+
console.log(chalk_1.default.bold.cyan(' Live request log\n'));
|
|
151
|
+
}
|
|
152
|
+
// ─── Dev command detection ───
|
|
153
|
+
function detectDevCommand(detected, cwd) {
|
|
154
|
+
// Check package.json scripts
|
|
155
|
+
const pkgPath = path_1.default.join(cwd, 'package.json');
|
|
156
|
+
let scripts = {};
|
|
157
|
+
if (fs_1.default.existsSync(pkgPath)) {
|
|
158
|
+
try {
|
|
159
|
+
const pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, 'utf-8'));
|
|
160
|
+
scripts = pkg.scripts || {};
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// ignore
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Framework-specific defaults
|
|
167
|
+
if (detected.framework === 'Next.js') {
|
|
168
|
+
return scripts['dev'] ? 'npm run dev' : 'npx next dev';
|
|
169
|
+
}
|
|
170
|
+
if (detected.framework === 'Remix') {
|
|
171
|
+
return scripts['dev'] ? 'npm run dev' : 'npx remix dev';
|
|
172
|
+
}
|
|
173
|
+
// Vite-based frameworks
|
|
174
|
+
const viteFws = ['React', 'Vue', 'SvelteKit', 'Astro'];
|
|
175
|
+
if (detected.framework && viteFws.includes(detected.framework)) {
|
|
176
|
+
return scripts['dev'] ? 'npm run dev' : 'npx vite dev';
|
|
177
|
+
}
|
|
178
|
+
// Generic — use "dev" script if available
|
|
179
|
+
if (scripts['dev']) {
|
|
180
|
+
return 'npm run dev';
|
|
181
|
+
}
|
|
182
|
+
// Fallback — look for common entry points
|
|
183
|
+
if (scripts['start']) {
|
|
184
|
+
return 'npm start';
|
|
185
|
+
}
|
|
186
|
+
return 'npm run dev';
|
|
187
|
+
}
|
|
188
|
+
function detectDevServerPort(devCmd) {
|
|
189
|
+
// Try to extract port from the command
|
|
190
|
+
const portMatch = devCmd.match(/(?:-p|--port)\s+(\d+)/);
|
|
191
|
+
if (portMatch) {
|
|
192
|
+
return parseInt(portMatch[1]);
|
|
193
|
+
}
|
|
194
|
+
// Default ports by framework
|
|
195
|
+
if (devCmd.includes('next'))
|
|
196
|
+
return 3000;
|
|
197
|
+
if (devCmd.includes('vite'))
|
|
198
|
+
return 5173;
|
|
199
|
+
if (devCmd.includes('remix'))
|
|
200
|
+
return 5173;
|
|
201
|
+
if (devCmd.includes('astro'))
|
|
202
|
+
return 4321;
|
|
203
|
+
return 3000;
|
|
204
|
+
}
|
|
205
|
+
function discoverRoutes(detected, cwd, routeFileMap) {
|
|
206
|
+
const pages = [];
|
|
207
|
+
const apiRoutes = [];
|
|
208
|
+
if (detected.framework === 'Next.js') {
|
|
209
|
+
// Next.js App Router
|
|
210
|
+
const appDir = fs_1.default.existsSync(path_1.default.join(cwd, 'src', 'app'))
|
|
211
|
+
? path_1.default.join(cwd, 'src', 'app')
|
|
212
|
+
: path_1.default.join(cwd, 'app');
|
|
213
|
+
if (fs_1.default.existsSync(appDir)) {
|
|
214
|
+
walkNextAppDir(appDir, appDir, pages, apiRoutes, routeFileMap);
|
|
215
|
+
}
|
|
216
|
+
// Next.js Pages Router
|
|
217
|
+
const pagesDir = fs_1.default.existsSync(path_1.default.join(cwd, 'src', 'pages'))
|
|
218
|
+
? path_1.default.join(cwd, 'src', 'pages')
|
|
219
|
+
: path_1.default.join(cwd, 'pages');
|
|
220
|
+
if (fs_1.default.existsSync(pagesDir)) {
|
|
221
|
+
walkNextPagesDir(pagesDir, pagesDir, pages, apiRoutes, routeFileMap);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { pages, apiRoutes };
|
|
225
|
+
}
|
|
226
|
+
function walkNextAppDir(dir, baseDir, pages, apiRoutes, routeFileMap) {
|
|
227
|
+
let entries;
|
|
228
|
+
try {
|
|
229
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
236
|
+
continue;
|
|
237
|
+
const fullPath = path_1.default.join(dir, entry.name);
|
|
238
|
+
if (entry.isDirectory()) {
|
|
239
|
+
walkNextAppDir(fullPath, baseDir, pages, apiRoutes, routeFileMap);
|
|
240
|
+
}
|
|
241
|
+
else if (entry.isFile()) {
|
|
242
|
+
const relativePath = path_1.default.relative(baseDir, dir);
|
|
243
|
+
const route = '/' + relativePath.replace(/\\/g, '/');
|
|
244
|
+
const normalizedRoute = route === '/.' ? '/' : route;
|
|
245
|
+
if (entry.name.match(/^route\.(ts|tsx|js|jsx)$/)) {
|
|
246
|
+
apiRoutes.push(normalizedRoute);
|
|
247
|
+
if (routeFileMap) {
|
|
248
|
+
routeFileMap.fileToRoute.set(fullPath, normalizedRoute);
|
|
249
|
+
routeFileMap.fileToType.set(fullPath, 'api');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else if (entry.name.match(/^page\.(ts|tsx|js|jsx)$/)) {
|
|
253
|
+
pages.push(normalizedRoute);
|
|
254
|
+
if (routeFileMap) {
|
|
255
|
+
routeFileMap.fileToRoute.set(fullPath, normalizedRoute);
|
|
256
|
+
routeFileMap.fileToType.set(fullPath, 'page');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function walkNextPagesDir(dir, baseDir, pages, apiRoutes, routeFileMap) {
|
|
263
|
+
let entries;
|
|
264
|
+
try {
|
|
265
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
for (const entry of entries) {
|
|
271
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_') || entry.name === 'node_modules')
|
|
272
|
+
continue;
|
|
273
|
+
const fullPath = path_1.default.join(dir, entry.name);
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
walkNextPagesDir(fullPath, baseDir, pages, apiRoutes, routeFileMap);
|
|
276
|
+
}
|
|
277
|
+
else if (entry.isFile() && entry.name.match(/\.(ts|tsx|js|jsx)$/)) {
|
|
278
|
+
const relativePath = path_1.default.relative(baseDir, fullPath);
|
|
279
|
+
const routePath = '/' + relativePath
|
|
280
|
+
.replace(/\\/g, '/')
|
|
281
|
+
.replace(/\.(ts|tsx|js|jsx)$/, '')
|
|
282
|
+
.replace(/\/index$/, '')
|
|
283
|
+
|| '/';
|
|
284
|
+
const isApi = relativePath.startsWith('api/') || relativePath.startsWith('api\\');
|
|
285
|
+
if (isApi) {
|
|
286
|
+
apiRoutes.push(routePath);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
pages.push(routePath);
|
|
290
|
+
}
|
|
291
|
+
if (routeFileMap) {
|
|
292
|
+
routeFileMap.fileToRoute.set(fullPath, routePath);
|
|
293
|
+
routeFileMap.fileToType.set(fullPath, isApi ? 'api' : 'page');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// ─── In-memory ScanWarp local server ───
|
|
299
|
+
const SPAN_KIND_MAP = {
|
|
300
|
+
0: 'UNSPECIFIED',
|
|
301
|
+
1: 'INTERNAL',
|
|
302
|
+
2: 'SERVER',
|
|
303
|
+
3: 'CLIENT',
|
|
304
|
+
4: 'PRODUCER',
|
|
305
|
+
5: 'CONSUMER',
|
|
306
|
+
};
|
|
307
|
+
const STATUS_CODE_MAP = {
|
|
308
|
+
0: 'UNSET',
|
|
309
|
+
1: 'OK',
|
|
310
|
+
2: 'ERROR',
|
|
311
|
+
};
|
|
312
|
+
async function startLocalServer(port) {
|
|
313
|
+
const store = {
|
|
314
|
+
spans: [],
|
|
315
|
+
events: [],
|
|
316
|
+
liveLogEnabled: false,
|
|
317
|
+
analysisEngine: new analysis_engine_js_1.AnalysisEngine(),
|
|
318
|
+
routes: { pages: [], apiRoutes: [] },
|
|
319
|
+
previousResults: new Map(),
|
|
320
|
+
baselines: new Map(),
|
|
321
|
+
schemaTracker: new schema_drift_js_1.SchemaTracker(),
|
|
322
|
+
startedAt: Date.now(),
|
|
323
|
+
};
|
|
324
|
+
const server = (0, http_1.createServer)((req, res) => {
|
|
325
|
+
let body = '';
|
|
326
|
+
req.on('data', (chunk) => {
|
|
327
|
+
body += chunk.toString();
|
|
328
|
+
});
|
|
329
|
+
req.on('end', () => {
|
|
330
|
+
// CORS headers
|
|
331
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
332
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
333
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-scanwarp-project-id');
|
|
334
|
+
if (req.method === 'OPTIONS') {
|
|
335
|
+
res.writeHead(204);
|
|
336
|
+
res.end();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
res.setHeader('Content-Type', 'application/json');
|
|
340
|
+
try {
|
|
341
|
+
// POST /v1/traces
|
|
342
|
+
if (req.method === 'POST' && req.url === '/v1/traces') {
|
|
343
|
+
handleTraceIngest(body, store);
|
|
344
|
+
res.writeHead(200);
|
|
345
|
+
res.end(JSON.stringify({ partialSuccess: {} }));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// POST /v1/metrics
|
|
349
|
+
if (req.method === 'POST' && req.url === '/v1/metrics') {
|
|
350
|
+
res.writeHead(200);
|
|
351
|
+
res.end(JSON.stringify({ partialSuccess: {} }));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// GET /health
|
|
355
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
356
|
+
res.writeHead(200);
|
|
357
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// ─── Data API (for MCP server) ───
|
|
361
|
+
// GET /api/status
|
|
362
|
+
if (req.method === 'GET' && req.url === '/api/status') {
|
|
363
|
+
const traceIds = new Set(store.spans.map((s) => s.trace_id));
|
|
364
|
+
const uptimeMs = Date.now() - store.startedAt;
|
|
365
|
+
const totalRoutes = store.routes.pages.length + store.routes.apiRoutes.length;
|
|
366
|
+
res.writeHead(200);
|
|
367
|
+
res.end(JSON.stringify({
|
|
368
|
+
uptime_ms: uptimeMs,
|
|
369
|
+
total_routes: totalRoutes,
|
|
370
|
+
pages: store.routes.pages.length,
|
|
371
|
+
api_routes: store.routes.apiRoutes.length,
|
|
372
|
+
total_spans: store.spans.length,
|
|
373
|
+
total_traces: traceIds.size,
|
|
374
|
+
active_issues: store.analysisEngine.activeIssueCount,
|
|
375
|
+
error_count: store.events.filter((e) => e.type === 'trace_error').length,
|
|
376
|
+
schema_baselines: store.schemaTracker.getBaselineCount(),
|
|
377
|
+
}));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// GET /api/issues
|
|
381
|
+
if (req.method === 'GET' && req.url === '/api/issues') {
|
|
382
|
+
const issues = store.analysisEngine.getActiveIssues();
|
|
383
|
+
res.writeHead(200);
|
|
384
|
+
res.end(JSON.stringify({ issues }));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// GET /api/routes
|
|
388
|
+
if (req.method === 'GET' && req.url === '/api/routes') {
|
|
389
|
+
const allRoutes = [
|
|
390
|
+
...store.routes.pages.map((r) => ({ path: r, type: 'page' })),
|
|
391
|
+
...store.routes.apiRoutes.map((r) => ({ path: r, type: 'api' })),
|
|
392
|
+
];
|
|
393
|
+
const routeData = allRoutes.map((route) => {
|
|
394
|
+
const lastCheck = store.previousResults.get(route.path);
|
|
395
|
+
const baseline = store.baselines.get(route.path);
|
|
396
|
+
let status = 'unknown';
|
|
397
|
+
if (lastCheck) {
|
|
398
|
+
if (lastCheck.status > 0 && lastCheck.status < 400) {
|
|
399
|
+
status = 'healthy';
|
|
400
|
+
if (baseline && lastCheck.timeMs > 500 && lastCheck.timeMs > baseline * 3) {
|
|
401
|
+
status = 'slow';
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
status = 'error';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
path: route.path,
|
|
410
|
+
type: route.type,
|
|
411
|
+
status,
|
|
412
|
+
last_status_code: lastCheck?.status ?? null,
|
|
413
|
+
last_time_ms: lastCheck?.timeMs ?? null,
|
|
414
|
+
baseline_ms: baseline ?? null,
|
|
415
|
+
error_text: lastCheck?.errorText ?? null,
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
res.writeHead(200);
|
|
419
|
+
res.end(JSON.stringify({ routes: routeData }));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// GET /api/slow-routes
|
|
423
|
+
if (req.method === 'GET' && req.url === '/api/slow-routes') {
|
|
424
|
+
const slowRoutes = [];
|
|
425
|
+
for (const [route, baseline] of store.baselines) {
|
|
426
|
+
const lastCheck = store.previousResults.get(route);
|
|
427
|
+
if (!lastCheck || lastCheck.status === 0 || lastCheck.status >= 400)
|
|
428
|
+
continue;
|
|
429
|
+
if (lastCheck.timeMs > 500 && lastCheck.timeMs > baseline * 3) {
|
|
430
|
+
// Find bottleneck span from recent traces for this route
|
|
431
|
+
const routeSpans = store.spans.filter((s) => {
|
|
432
|
+
const spanRoute = s.attributes['http.route'] || s.attributes['http.target'] || s.attributes['url.path'];
|
|
433
|
+
return spanRoute === route && s.kind === 'SERVER';
|
|
434
|
+
});
|
|
435
|
+
let bottleneck = null;
|
|
436
|
+
if (routeSpans.length > 0) {
|
|
437
|
+
const latestTrace = routeSpans[routeSpans.length - 1];
|
|
438
|
+
const traceSpans = store.spans
|
|
439
|
+
.filter((s) => s.trace_id === latestTrace.trace_id && s.span_id !== latestTrace.span_id)
|
|
440
|
+
.sort((a, b) => b.duration_ms - a.duration_ms);
|
|
441
|
+
if (traceSpans.length > 0) {
|
|
442
|
+
bottleneck = { name: traceSpans[0].operation_name, duration_ms: traceSpans[0].duration_ms };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
slowRoutes.push({
|
|
446
|
+
path: route,
|
|
447
|
+
current_ms: lastCheck.timeMs,
|
|
448
|
+
baseline_ms: baseline,
|
|
449
|
+
ratio: Math.round((lastCheck.timeMs / baseline) * 10) / 10,
|
|
450
|
+
bottleneck,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
res.writeHead(200);
|
|
455
|
+
res.end(JSON.stringify({ slow_routes: slowRoutes }));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// GET /api/route-traces?path=/api/foo&limit=5
|
|
459
|
+
if (req.method === 'GET' && req.url?.startsWith('/api/route-traces')) {
|
|
460
|
+
const urlObj = new URL(req.url, 'http://localhost');
|
|
461
|
+
const routePath = urlObj.searchParams.get('path') || '/';
|
|
462
|
+
const limit = parseInt(urlObj.searchParams.get('limit') || '5');
|
|
463
|
+
// Find SERVER spans for this route
|
|
464
|
+
const serverSpans = store.spans
|
|
465
|
+
.filter((s) => {
|
|
466
|
+
const spanRoute = s.attributes['http.route'] || s.attributes['http.target'] || s.attributes['url.path'];
|
|
467
|
+
return s.kind === 'SERVER' && spanRoute === routePath;
|
|
468
|
+
})
|
|
469
|
+
.sort((a, b) => b.start_time - a.start_time)
|
|
470
|
+
.slice(0, limit);
|
|
471
|
+
const traces = serverSpans.map((rootSpan) => {
|
|
472
|
+
const traceSpans = store.spans
|
|
473
|
+
.filter((s) => s.trace_id === rootSpan.trace_id)
|
|
474
|
+
.sort((a, b) => a.start_time - b.start_time);
|
|
475
|
+
return {
|
|
476
|
+
trace_id: rootSpan.trace_id,
|
|
477
|
+
method: rootSpan.attributes['http.method'] || rootSpan.attributes['http.request.method'] || '???',
|
|
478
|
+
route: routePath,
|
|
479
|
+
status_code: rootSpan.attributes['http.status_code'] || rootSpan.attributes['http.response.status_code'],
|
|
480
|
+
duration_ms: rootSpan.duration_ms,
|
|
481
|
+
timestamp: rootSpan.start_time,
|
|
482
|
+
spans: traceSpans.map((s) => ({
|
|
483
|
+
span_id: s.span_id,
|
|
484
|
+
parent_span_id: s.parent_span_id,
|
|
485
|
+
operation: s.operation_name,
|
|
486
|
+
kind: s.kind,
|
|
487
|
+
duration_ms: s.duration_ms,
|
|
488
|
+
status: s.status_code,
|
|
489
|
+
service: s.service_name,
|
|
490
|
+
attributes: s.attributes,
|
|
491
|
+
})),
|
|
492
|
+
};
|
|
493
|
+
});
|
|
494
|
+
res.writeHead(200);
|
|
495
|
+
res.end(JSON.stringify({ path: routePath, traces }));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// Catch-all for unknown routes
|
|
499
|
+
res.writeHead(404);
|
|
500
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
res.writeHead(500);
|
|
504
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
server.listen(port, '127.0.0.1', () => {
|
|
510
|
+
resolve({ server, store });
|
|
511
|
+
});
|
|
512
|
+
server.on('error', reject);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
function handleTraceIngest(body, store) {
|
|
516
|
+
let payload;
|
|
517
|
+
try {
|
|
518
|
+
payload = JSON.parse(body);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (!payload.resourceSpans)
|
|
524
|
+
return;
|
|
525
|
+
for (const resourceSpan of payload.resourceSpans) {
|
|
526
|
+
const serviceName = extractServiceName(resourceSpan.resource) || 'unknown';
|
|
527
|
+
for (const scopeSpan of resourceSpan.scopeSpans || []) {
|
|
528
|
+
for (const otlpSpan of scopeSpan.spans || []) {
|
|
529
|
+
const startTimeNano = BigInt(otlpSpan.startTimeUnixNano);
|
|
530
|
+
const endTimeNano = BigInt(otlpSpan.endTimeUnixNano);
|
|
531
|
+
const startTimeMs = Number(startTimeNano / BigInt(1_000_000));
|
|
532
|
+
const durationMs = Number((endTimeNano - startTimeNano) / BigInt(1_000_000));
|
|
533
|
+
const statusCode = STATUS_CODE_MAP[otlpSpan.status?.code ?? 0] || 'UNSET';
|
|
534
|
+
const attributes = flattenAttributes(otlpSpan.attributes);
|
|
535
|
+
const spanEvents = (otlpSpan.events || []).map((e) => ({
|
|
536
|
+
name: e.name,
|
|
537
|
+
attributes: flattenAttributes(e.attributes),
|
|
538
|
+
}));
|
|
539
|
+
const span = {
|
|
540
|
+
trace_id: otlpSpan.traceId,
|
|
541
|
+
span_id: otlpSpan.spanId,
|
|
542
|
+
parent_span_id: otlpSpan.parentSpanId || null,
|
|
543
|
+
service_name: serviceName,
|
|
544
|
+
operation_name: otlpSpan.name,
|
|
545
|
+
kind: SPAN_KIND_MAP[otlpSpan.kind] || 'UNSPECIFIED',
|
|
546
|
+
start_time: startTimeMs,
|
|
547
|
+
duration_ms: durationMs,
|
|
548
|
+
status_code: statusCode,
|
|
549
|
+
status_message: otlpSpan.status?.message || null,
|
|
550
|
+
attributes,
|
|
551
|
+
events: spanEvents,
|
|
552
|
+
};
|
|
553
|
+
store.spans.push(span);
|
|
554
|
+
// Live request log — print a line for each SERVER span (incoming HTTP request)
|
|
555
|
+
if (store.liveLogEnabled && span.kind === 'SERVER') {
|
|
556
|
+
printRequestLogLine(span);
|
|
557
|
+
}
|
|
558
|
+
// Track errors (non-SERVER spans that are errors, or SERVER errors already printed above)
|
|
559
|
+
if (statusCode === 'ERROR') {
|
|
560
|
+
const event = {
|
|
561
|
+
id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
562
|
+
type: 'trace_error',
|
|
563
|
+
source: 'otel',
|
|
564
|
+
message: `Error in ${serviceName}: ${otlpSpan.name}${otlpSpan.status?.message ? ` — ${otlpSpan.status.message}` : ''}`,
|
|
565
|
+
severity: 'high',
|
|
566
|
+
created_at: new Date(),
|
|
567
|
+
};
|
|
568
|
+
store.events.push(event);
|
|
569
|
+
// Print non-SERVER errors separately (SERVER errors show in the request log)
|
|
570
|
+
if (!store.liveLogEnabled || span.kind !== 'SERVER') {
|
|
571
|
+
console.log(chalk_1.default.red(` ✗ Error: ${span.operation_name} (${durationMs}ms) — ${serviceName}`));
|
|
572
|
+
if (otlpSpan.status?.message) {
|
|
573
|
+
console.log(chalk_1.default.gray(` ${otlpSpan.status.message}`));
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Track slow queries
|
|
578
|
+
if (attributes['db.system'] && durationMs > 1000) {
|
|
579
|
+
const event = {
|
|
580
|
+
id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
581
|
+
type: 'slow_query',
|
|
582
|
+
source: 'otel',
|
|
583
|
+
message: `Slow ${attributes['db.system']} query: ${otlpSpan.name} (${durationMs}ms)`,
|
|
584
|
+
severity: 'medium',
|
|
585
|
+
created_at: new Date(),
|
|
586
|
+
};
|
|
587
|
+
store.events.push(event);
|
|
588
|
+
if (store.liveLogEnabled) {
|
|
589
|
+
console.log(chalk_1.default.yellow(` ⚠ slow query: ${attributes['db.system']}: ${otlpSpan.name} (${durationMs}ms)`));
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
console.log(chalk_1.default.yellow(` ⚠ Slow query: ${attributes['db.system']}: ${otlpSpan.name} (${durationMs}ms)`));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Keep memory bounded — retain last 5000 spans
|
|
599
|
+
if (store.spans.length > 5000) {
|
|
600
|
+
store.spans = store.spans.slice(-5000);
|
|
601
|
+
}
|
|
602
|
+
if (store.events.length > 1000) {
|
|
603
|
+
store.events = store.events.slice(-1000);
|
|
604
|
+
}
|
|
605
|
+
// Run analysis on complete traces
|
|
606
|
+
if (store.liveLogEnabled) {
|
|
607
|
+
// Collect trace IDs from the spans we just ingested
|
|
608
|
+
const newTraceIds = new Set();
|
|
609
|
+
for (const resourceSpan of payload.resourceSpans) {
|
|
610
|
+
for (const scopeSpan of resourceSpan.scopeSpans || []) {
|
|
611
|
+
for (const otlpSpan of scopeSpan.spans || []) {
|
|
612
|
+
newTraceIds.add(otlpSpan.traceId);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// For each trace, gather all known spans and run analysis
|
|
617
|
+
for (const traceId of newTraceIds) {
|
|
618
|
+
const traceSpans = store.spans.filter((s) => s.trace_id === traceId);
|
|
619
|
+
store.analysisEngine.analyzeTrace(traceSpans);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Print a single request log line in the format:
|
|
625
|
+
* 14:02:01 ✓ GET /api/products 34ms
|
|
626
|
+
* 14:02:05 ✗ POST /api/checkout 0ms TypeError: Cannot read...
|
|
627
|
+
*/
|
|
628
|
+
function printRequestLogLine(span) {
|
|
629
|
+
const now = new Date(span.start_time);
|
|
630
|
+
const time = chalk_1.default.gray(`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`);
|
|
631
|
+
const attrs = span.attributes;
|
|
632
|
+
const method = String(attrs['http.method'] || attrs['http.request.method'] || '???');
|
|
633
|
+
const route = String(attrs['http.route'] || attrs['http.target'] || attrs['url.path'] || span.operation_name);
|
|
634
|
+
const httpStatus = attrs['http.status_code'] || attrs['http.response.status_code'];
|
|
635
|
+
const isError = span.status_code === 'ERROR' ||
|
|
636
|
+
(typeof httpStatus === 'number' && httpStatus >= 400);
|
|
637
|
+
const icon = isError ? chalk_1.default.red('✗') : chalk_1.default.green('✓');
|
|
638
|
+
const label = `${method} ${route}`;
|
|
639
|
+
const durationStr = `${span.duration_ms}ms`.padStart(6);
|
|
640
|
+
const durationColor = span.duration_ms > 1000 ? chalk_1.default.yellow(durationStr) : chalk_1.default.white(durationStr);
|
|
641
|
+
// Build error suffix
|
|
642
|
+
let errorSuffix = '';
|
|
643
|
+
if (isError) {
|
|
644
|
+
// Try to get an error message from span events
|
|
645
|
+
const exceptionEvent = span.events.find((e) => e.name === 'exception');
|
|
646
|
+
const exceptionMsg = exceptionEvent?.attributes?.['exception.message'];
|
|
647
|
+
if (typeof exceptionMsg === 'string') {
|
|
648
|
+
const truncated = exceptionMsg.length > 50 ? exceptionMsg.substring(0, 50) + '...' : exceptionMsg;
|
|
649
|
+
errorSuffix = ` ${chalk_1.default.red(truncated)}`;
|
|
650
|
+
}
|
|
651
|
+
else if (span.status_message) {
|
|
652
|
+
const truncated = span.status_message.length > 50 ? span.status_message.substring(0, 50) + '...' : span.status_message;
|
|
653
|
+
errorSuffix = ` ${chalk_1.default.red(truncated)}`;
|
|
654
|
+
}
|
|
655
|
+
else if (typeof httpStatus === 'number') {
|
|
656
|
+
errorSuffix = ` ${chalk_1.default.red(String(httpStatus))}`;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
console.log(` ${time} ${icon} ${label.padEnd(28)} ${durationColor}${errorSuffix}`);
|
|
660
|
+
}
|
|
661
|
+
function extractServiceName(resource) {
|
|
662
|
+
if (!resource?.attributes)
|
|
663
|
+
return undefined;
|
|
664
|
+
for (const attr of resource.attributes) {
|
|
665
|
+
if (attr.key === 'service.name') {
|
|
666
|
+
return attr.value.stringValue;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
function flattenAttributes(attrs) {
|
|
672
|
+
if (!attrs)
|
|
673
|
+
return {};
|
|
674
|
+
const result = {};
|
|
675
|
+
for (const attr of attrs) {
|
|
676
|
+
const val = attr.value;
|
|
677
|
+
if (val.stringValue !== undefined)
|
|
678
|
+
result[attr.key] = val.stringValue;
|
|
679
|
+
else if (val.intValue !== undefined)
|
|
680
|
+
result[attr.key] = Number(val.intValue);
|
|
681
|
+
else if (val.doubleValue !== undefined)
|
|
682
|
+
result[attr.key] = val.doubleValue;
|
|
683
|
+
else if (val.boolValue !== undefined)
|
|
684
|
+
result[attr.key] = val.boolValue;
|
|
685
|
+
}
|
|
686
|
+
return result;
|
|
687
|
+
}
|
|
688
|
+
// ─── Child process management ───
|
|
689
|
+
function startDevServer(devCmd, cwd, scanwarpPort, isNextJs) {
|
|
690
|
+
const [cmd, ...args] = parseCommand(devCmd);
|
|
691
|
+
// Build env vars
|
|
692
|
+
const env = {
|
|
693
|
+
...process.env,
|
|
694
|
+
SCANWARP_SERVER: `http://localhost:${scanwarpPort}`,
|
|
695
|
+
SCANWARP_PROJECT_ID: 'local-dev',
|
|
696
|
+
SCANWARP_SERVICE_NAME: 'dev',
|
|
697
|
+
};
|
|
698
|
+
// For non-Next.js, inject NODE_OPTIONS to auto-load instrumentation
|
|
699
|
+
if (!isNextJs) {
|
|
700
|
+
const existingNodeOpts = process.env.NODE_OPTIONS || '';
|
|
701
|
+
env.NODE_OPTIONS = `--require @scanwarp/instrument ${existingNodeOpts}`.trim();
|
|
702
|
+
}
|
|
703
|
+
const child = (0, child_process_1.spawn)(cmd, args, {
|
|
704
|
+
cwd,
|
|
705
|
+
env,
|
|
706
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
707
|
+
shell: true,
|
|
708
|
+
});
|
|
709
|
+
return child;
|
|
710
|
+
}
|
|
711
|
+
function parseCommand(cmd) {
|
|
712
|
+
// Simple command parsing — split on spaces but respect quotes
|
|
713
|
+
const parts = [];
|
|
714
|
+
let current = '';
|
|
715
|
+
let inQuote = null;
|
|
716
|
+
for (const ch of cmd) {
|
|
717
|
+
if (inQuote) {
|
|
718
|
+
if (ch === inQuote) {
|
|
719
|
+
inQuote = null;
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
current += ch;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
else if (ch === '"' || ch === "'") {
|
|
726
|
+
inQuote = ch;
|
|
727
|
+
}
|
|
728
|
+
else if (ch === ' ') {
|
|
729
|
+
if (current) {
|
|
730
|
+
parts.push(current);
|
|
731
|
+
current = '';
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
current += ch;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (current)
|
|
739
|
+
parts.push(current);
|
|
740
|
+
return parts;
|
|
741
|
+
}
|
|
742
|
+
// ─── Server readiness check ───
|
|
743
|
+
async function waitForServer(port, timeoutMs) {
|
|
744
|
+
const start = Date.now();
|
|
745
|
+
const spinner = (0, ora_1.default)('Waiting for dev server to be ready...').start();
|
|
746
|
+
while (Date.now() - start < timeoutMs) {
|
|
747
|
+
try {
|
|
748
|
+
const controller = new AbortController();
|
|
749
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
750
|
+
const response = await fetch(`http://localhost:${port}`, {
|
|
751
|
+
signal: controller.signal,
|
|
752
|
+
});
|
|
753
|
+
clearTimeout(timeout);
|
|
754
|
+
if (response.ok || response.status < 500) {
|
|
755
|
+
spinner.succeed(`Dev server ready on port ${port}`);
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
// Not ready yet
|
|
761
|
+
}
|
|
762
|
+
await sleep(500);
|
|
763
|
+
}
|
|
764
|
+
spinner.warn('Dev server did not respond in time — skipping route check');
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
// ─── Route crawling ───
|
|
768
|
+
async function crawlRoutes(routes, port, schemaTracker) {
|
|
769
|
+
const allGetRoutes = [
|
|
770
|
+
...routes.pages,
|
|
771
|
+
...routes.apiRoutes.filter((r) => !r.includes('[') || !r.includes(']')),
|
|
772
|
+
];
|
|
773
|
+
// Filter out dynamic routes (contain [...] or [param]) since we can't crawl them
|
|
774
|
+
const staticRoutes = allGetRoutes.filter((r) => !r.includes('['));
|
|
775
|
+
if (staticRoutes.length === 0) {
|
|
776
|
+
console.log(chalk_1.default.gray(' No static routes to check (all routes are dynamic)\n'));
|
|
777
|
+
return [];
|
|
778
|
+
}
|
|
779
|
+
console.log(chalk_1.default.bold(' Initial scan:\n'));
|
|
780
|
+
const checkResults = await checkRoutes(staticRoutes, port);
|
|
781
|
+
printRouteResults(checkResults, { schemaTracker });
|
|
782
|
+
return checkResults;
|
|
783
|
+
}
|
|
784
|
+
/** Hit each route with GET and return results */
|
|
785
|
+
async function checkRoutes(routes, port) {
|
|
786
|
+
const results = [];
|
|
787
|
+
for (const route of routes) {
|
|
788
|
+
try {
|
|
789
|
+
const start = Date.now();
|
|
790
|
+
const controller = new AbortController();
|
|
791
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
792
|
+
const response = await fetch(`http://localhost:${port}${route}`, {
|
|
793
|
+
signal: controller.signal,
|
|
794
|
+
redirect: 'follow',
|
|
795
|
+
});
|
|
796
|
+
clearTimeout(timeout);
|
|
797
|
+
const timeMs = Date.now() - start;
|
|
798
|
+
// Try to extract error text from non-ok responses
|
|
799
|
+
let errorText;
|
|
800
|
+
let responseBody;
|
|
801
|
+
if (response.status >= 400) {
|
|
802
|
+
try {
|
|
803
|
+
const text = await response.text();
|
|
804
|
+
try {
|
|
805
|
+
const json = JSON.parse(text);
|
|
806
|
+
errorText = json.error || json.message || undefined;
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
const firstLine = text.split('\n')[0];
|
|
810
|
+
if (firstLine && firstLine.length > 0) {
|
|
811
|
+
errorText = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// ignore
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
else if (response.status >= 200 && response.status < 300 && route.startsWith('/api/')) {
|
|
820
|
+
// Read JSON body for 2xx API routes (schema drift detection)
|
|
821
|
+
try {
|
|
822
|
+
const contentType = response.headers.get('content-type') || '';
|
|
823
|
+
if (contentType.includes('application/json')) {
|
|
824
|
+
responseBody = await response.json();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
// Not valid JSON — skip
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
results.push({ route, method: 'GET', status: response.status, timeMs, errorText, responseBody });
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
results.push({
|
|
835
|
+
route,
|
|
836
|
+
method: 'GET',
|
|
837
|
+
status: 0,
|
|
838
|
+
timeMs: 0,
|
|
839
|
+
errorText: err instanceof Error ? err.message : String(err),
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return results;
|
|
844
|
+
}
|
|
845
|
+
/** Print route check results with aligned columns */
|
|
846
|
+
function printRouteResults(results, opts = {}) {
|
|
847
|
+
if (results.length === 0)
|
|
848
|
+
return;
|
|
849
|
+
const { previousResults, baselines, quiet } = opts;
|
|
850
|
+
const maxRouteLen = Math.max(...results.map((r) => `${r.method} ${r.route}`.length));
|
|
851
|
+
let printedCount = 0;
|
|
852
|
+
let suppressedOkCount = 0;
|
|
853
|
+
for (const r of results) {
|
|
854
|
+
const isOk = r.status > 0 && r.status < 400;
|
|
855
|
+
const label = `${r.method} ${r.route}`;
|
|
856
|
+
const padded = label.padEnd(maxRouteLen + 2);
|
|
857
|
+
const timeStr = `${r.timeMs}ms`.padStart(6);
|
|
858
|
+
const timeColor = r.timeMs > 1000 ? chalk_1.default.yellow(timeStr) : chalk_1.default.gray(timeStr);
|
|
859
|
+
const statusStr = r.status === 0 ? '' : (isOk ? '' : ` ${chalk_1.default.red(String(r.status))}`);
|
|
860
|
+
const errStr = (!isOk && r.errorText) ? ` ${chalk_1.default.gray(r.errorText)}` : '';
|
|
861
|
+
// Change indicator compared to previous results
|
|
862
|
+
let changeIndicator = '';
|
|
863
|
+
let hasChange = false;
|
|
864
|
+
if (previousResults) {
|
|
865
|
+
const prev = previousResults.get(r.route);
|
|
866
|
+
if (prev) {
|
|
867
|
+
const prevOk = prev.status > 0 && prev.status < 400;
|
|
868
|
+
if (!prevOk && isOk) {
|
|
869
|
+
changeIndicator = ` ${chalk_1.default.green('FIXED')}`;
|
|
870
|
+
hasChange = true;
|
|
871
|
+
}
|
|
872
|
+
else if (prevOk && !isOk) {
|
|
873
|
+
changeIndicator = ` ${chalk_1.default.red('BROKE')}`;
|
|
874
|
+
hasChange = true;
|
|
875
|
+
}
|
|
876
|
+
else if (prev.status !== r.status) {
|
|
877
|
+
changeIndicator = ` ${chalk_1.default.yellow(`${prev.status}→${r.status}`)}`;
|
|
878
|
+
hasChange = true;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
changeIndicator = ` ${chalk_1.default.cyan('NEW')}`;
|
|
883
|
+
hasChange = true;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
// Slow regression detection: 3x baseline AND over 500ms
|
|
887
|
+
let slowIndicator = '';
|
|
888
|
+
if (isOk && baselines) {
|
|
889
|
+
const baseline = baselines.get(r.route);
|
|
890
|
+
if (baseline !== undefined && r.timeMs > 500 && r.timeMs > baseline * 3) {
|
|
891
|
+
slowIndicator = ` ${chalk_1.default.yellow(`SLOW (baseline: ${baseline}ms)`)}`;
|
|
892
|
+
hasChange = true;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// In quiet mode, skip routes that are OK with no changes and no slow regression
|
|
896
|
+
if (quiet && isOk && !hasChange) {
|
|
897
|
+
suppressedOkCount++;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const icon = isOk ? chalk_1.default.green('✓') : chalk_1.default.red('✗');
|
|
901
|
+
console.log(` ${icon} ${padded} ${timeColor}${statusStr}${errStr}${changeIndicator}${slowIndicator}`);
|
|
902
|
+
printedCount++;
|
|
903
|
+
}
|
|
904
|
+
// Summary
|
|
905
|
+
const okCount = results.filter((r) => r.status > 0 && r.status < 400).length;
|
|
906
|
+
const errCount = results.filter((r) => r.status === 0 || r.status >= 400).length;
|
|
907
|
+
const validResults = results.filter((r) => r.status > 0);
|
|
908
|
+
const avgTime = validResults.length > 0
|
|
909
|
+
? Math.round(validResults.reduce((sum, r) => sum + r.timeMs, 0) / validResults.length)
|
|
910
|
+
: 0;
|
|
911
|
+
if (quiet && suppressedOkCount > 0 && printedCount > 0) {
|
|
912
|
+
console.log(chalk_1.default.gray(` ... (${suppressedOkCount} more OK)`));
|
|
913
|
+
}
|
|
914
|
+
// In quiet mode with nothing interesting, print a single line
|
|
915
|
+
if (quiet && printedCount === 0) {
|
|
916
|
+
console.log(chalk_1.default.gray(` ✓ All ${okCount} routes OK (avg ${avgTime}ms)`));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
console.log('');
|
|
920
|
+
console.log(chalk_1.default.gray(` ${okCount} ok, ${errCount} errors, avg ${avgTime}ms`));
|
|
921
|
+
// Schema drift detection for API routes
|
|
922
|
+
if (opts.schemaTracker) {
|
|
923
|
+
for (const r of results) {
|
|
924
|
+
if (r.responseBody !== undefined && r.status >= 200 && r.status < 300) {
|
|
925
|
+
const diffs = opts.schemaTracker.processResponse(r.route, r.method, r.responseBody);
|
|
926
|
+
schema_drift_js_1.SchemaTracker.printDrift(r.route, r.method, diffs);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// ─── File watcher ───
|
|
932
|
+
function startFileWatcher(cwd, routes, routeFileMap, previousResults, baselines, schemaTracker, devServerPort) {
|
|
933
|
+
let debounceTimer = null;
|
|
934
|
+
let pendingFiles = new Set();
|
|
935
|
+
const watcher = (0, chokidar_1.watch)(['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs'], {
|
|
936
|
+
cwd,
|
|
937
|
+
ignored: ['**/node_modules/**', '**/.next/**', '**/.git/**', '**/dist/**', '**/build/**'],
|
|
938
|
+
ignoreInitial: true,
|
|
939
|
+
});
|
|
940
|
+
watcher.on('change', (relativePath) => {
|
|
941
|
+
const fullPath = path_1.default.resolve(cwd, relativePath);
|
|
942
|
+
pendingFiles.add(fullPath);
|
|
943
|
+
// Debounce — wait 1 second after the last change
|
|
944
|
+
if (debounceTimer)
|
|
945
|
+
clearTimeout(debounceTimer);
|
|
946
|
+
debounceTimer = setTimeout(async () => {
|
|
947
|
+
const changedFiles = new Set(pendingFiles);
|
|
948
|
+
pendingFiles = new Set();
|
|
949
|
+
const affectedRoutes = resolveAffectedRoutes(changedFiles, routes, routeFileMap);
|
|
950
|
+
if (affectedRoutes.length === 0)
|
|
951
|
+
return;
|
|
952
|
+
// Reset schema baselines for routes whose handler files changed
|
|
953
|
+
schemaTracker.resetForRoutes(affectedRoutes);
|
|
954
|
+
const fileNames = [...changedFiles].map((f) => path_1.default.relative(cwd, f)).join(', ');
|
|
955
|
+
console.log('');
|
|
956
|
+
console.log(chalk_1.default.gray('─'.repeat(60)));
|
|
957
|
+
console.log(chalk_1.default.bold.cyan(`\n File changed: ${chalk_1.default.white(fileNames)}\n`));
|
|
958
|
+
console.log(chalk_1.default.bold(` Re-checking ${affectedRoutes.length} route${affectedRoutes.length > 1 ? 's' : ''}...\n`));
|
|
959
|
+
const newResults = await checkRoutes(affectedRoutes, devServerPort);
|
|
960
|
+
printRouteResults(newResults, { previousResults, baselines, quiet: true, schemaTracker });
|
|
961
|
+
// Update previous results and baselines
|
|
962
|
+
for (const r of newResults) {
|
|
963
|
+
previousResults.set(r.route, r);
|
|
964
|
+
// Set baseline on first success (don't overwrite existing baselines)
|
|
965
|
+
if (r.status > 0 && r.status < 400 && !baselines.has(r.route)) {
|
|
966
|
+
baselines.set(r.route, r.timeMs);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
console.log(chalk_1.default.gray('\n─'.repeat(60)));
|
|
970
|
+
console.log('');
|
|
971
|
+
}, 1000);
|
|
972
|
+
});
|
|
973
|
+
return watcher;
|
|
974
|
+
}
|
|
975
|
+
/** Map changed files to affected routes */
|
|
976
|
+
function resolveAffectedRoutes(changedFiles, routes, routeFileMap) {
|
|
977
|
+
const affected = new Set();
|
|
978
|
+
let hasNonRouteFile = false;
|
|
979
|
+
for (const file of changedFiles) {
|
|
980
|
+
const route = routeFileMap.fileToRoute.get(file);
|
|
981
|
+
if (route) {
|
|
982
|
+
// Direct match — this file IS a route file
|
|
983
|
+
affected.add(route);
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
// Not a known route file — could be a utility, component, etc.
|
|
987
|
+
hasNonRouteFile = true;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// For non-route files, re-check all API routes (they're more likely to be affected by shared code)
|
|
991
|
+
if (hasNonRouteFile) {
|
|
992
|
+
const staticApiRoutes = routes.apiRoutes.filter((r) => !r.includes('['));
|
|
993
|
+
for (const route of staticApiRoutes) {
|
|
994
|
+
affected.add(route);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return [...affected];
|
|
998
|
+
}
|
|
999
|
+
// ─── Session summary ───
|
|
1000
|
+
function printSessionSummary(store) {
|
|
1001
|
+
const totalSpans = store.spans.length;
|
|
1002
|
+
const errorEvents = store.events.filter((e) => e.type === 'trace_error');
|
|
1003
|
+
const slowQueries = store.events.filter((e) => e.type === 'slow_query');
|
|
1004
|
+
const traceIds = new Set(store.spans.map((s) => s.trace_id));
|
|
1005
|
+
console.log(chalk_1.default.bold.cyan('\n Session Summary\n'));
|
|
1006
|
+
console.log(chalk_1.default.gray(` Traces: ${traceIds.size}`));
|
|
1007
|
+
console.log(chalk_1.default.gray(` Spans: ${totalSpans}`));
|
|
1008
|
+
if (errorEvents.length > 0) {
|
|
1009
|
+
console.log(chalk_1.default.red(` Errors: ${errorEvents.length}`));
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
console.log(chalk_1.default.green(` Errors: 0`));
|
|
1013
|
+
}
|
|
1014
|
+
if (slowQueries.length > 0) {
|
|
1015
|
+
console.log(chalk_1.default.yellow(` Slow queries: ${slowQueries.length}`));
|
|
1016
|
+
}
|
|
1017
|
+
// Analysis summary
|
|
1018
|
+
const analysis = store.analysisEngine.getSummary();
|
|
1019
|
+
if (analysis.total > 0) {
|
|
1020
|
+
console.log('');
|
|
1021
|
+
console.log(chalk_1.default.bold(' Analysis:'));
|
|
1022
|
+
if (analysis.active > 0) {
|
|
1023
|
+
console.log(chalk_1.default.yellow(` Active issues: ${analysis.active}`));
|
|
1024
|
+
}
|
|
1025
|
+
if (analysis.resolved > 0) {
|
|
1026
|
+
console.log(chalk_1.default.green(` Resolved: ${analysis.resolved}`));
|
|
1027
|
+
}
|
|
1028
|
+
for (const [rule, count] of analysis.byRule) {
|
|
1029
|
+
console.log(chalk_1.default.gray(` ${rule}: ${count}`));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
console.log('');
|
|
1033
|
+
}
|
|
1034
|
+
// ─── Utilities ───
|
|
1035
|
+
function sleep(ms) {
|
|
1036
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1037
|
+
}
|
|
1038
|
+
async function findAvailablePort(preferred) {
|
|
1039
|
+
return new Promise((resolve) => {
|
|
1040
|
+
const server = (0, http_1.createServer)();
|
|
1041
|
+
server.listen(preferred, '127.0.0.1', () => {
|
|
1042
|
+
server.close(() => resolve(preferred));
|
|
1043
|
+
});
|
|
1044
|
+
server.on('error', () => {
|
|
1045
|
+
// Port in use — try next
|
|
1046
|
+
const server2 = (0, http_1.createServer)();
|
|
1047
|
+
server2.listen(0, '127.0.0.1', () => {
|
|
1048
|
+
const addr = server2.address();
|
|
1049
|
+
const port = typeof addr === 'object' && addr ? addr.port : preferred + 1;
|
|
1050
|
+
server2.close(() => resolve(port));
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
}
|