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.
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ /**
3
+ * Analysis engine for scanwarp dev.
4
+ *
5
+ * Runs analyzers on incoming traces, deduplicates results so each unique
6
+ * issue is shown only once, and prints "resolved" when an issue goes away.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.AnalysisEngine = void 0;
13
+ const chalk_1 = __importDefault(require("chalk"));
14
+ const analyzers_js_1 = require("./analyzers.js");
15
+ class AnalysisEngine {
16
+ analyzers;
17
+ issues = new Map();
18
+ /** Track which issues fired in the most recent analysis pass per trace */
19
+ lastTraceIssueKeys = new Set();
20
+ constructor(analyzers) {
21
+ this.analyzers = analyzers ?? analyzers_js_1.defaultAnalyzers;
22
+ }
23
+ /**
24
+ * Analyze a complete trace (all spans with the same trace_id).
25
+ * Returns new/changed results that should be printed.
26
+ */
27
+ analyzeTrace(spans) {
28
+ if (spans.length === 0)
29
+ return;
30
+ const now = Date.now();
31
+ const currentKeys = new Set();
32
+ // Run all analyzers
33
+ for (const analyzer of this.analyzers) {
34
+ const results = analyzer.analyze(spans);
35
+ for (const result of results) {
36
+ const key = this.makeKey(result);
37
+ currentKeys.add(key);
38
+ const existing = this.issues.get(key);
39
+ if (existing) {
40
+ existing.hitCount++;
41
+ existing.lastSeen = now;
42
+ // Don't re-print — already shown
43
+ }
44
+ else {
45
+ // New issue — track and print
46
+ const tracked = {
47
+ result,
48
+ key,
49
+ hitCount: 1,
50
+ firstSeen: now,
51
+ lastSeen: now,
52
+ printed: true,
53
+ resolved: false,
54
+ };
55
+ this.issues.set(key, tracked);
56
+ this.printResult(result);
57
+ }
58
+ }
59
+ }
60
+ // Check for resolved issues — issues that were active in the previous trace
61
+ // from the same route but are no longer firing
62
+ for (const [key, issue] of this.issues) {
63
+ if (this.lastTraceIssueKeys.has(key) &&
64
+ !currentKeys.has(key) &&
65
+ !issue.resolved) {
66
+ issue.resolved = true;
67
+ this.printResolved(issue);
68
+ }
69
+ }
70
+ this.lastTraceIssueKeys = currentKeys;
71
+ }
72
+ /** Generate a dedup key from rule + core message content */
73
+ makeKey(result) {
74
+ // Strip numbers/counts from the message so "executed 23 times" and "executed 24 times"
75
+ // don't create separate entries
76
+ const normalizedMessage = result.message
77
+ .replace(/\d+\s*times/g, 'N times')
78
+ .replace(/\d+ms/g, 'Nms');
79
+ return `${result.rule}:${normalizedMessage}`;
80
+ }
81
+ /** Print a new analysis result inline */
82
+ printResult(result) {
83
+ const icon = result.severity === 'error'
84
+ ? chalk_1.default.red('!')
85
+ : result.severity === 'warning'
86
+ ? chalk_1.default.yellow('!')
87
+ : chalk_1.default.blue('i');
88
+ const severityColor = result.severity === 'error'
89
+ ? chalk_1.default.red
90
+ : result.severity === 'warning'
91
+ ? chalk_1.default.yellow
92
+ : chalk_1.default.blue;
93
+ const tag = severityColor(`[${result.rule}]`);
94
+ console.log(`\n ${icon} ${tag} ${result.message}`);
95
+ if (result.suggestion) {
96
+ console.log(chalk_1.default.gray(` → ${result.suggestion}`));
97
+ }
98
+ }
99
+ /** Print when an issue is resolved */
100
+ printResolved(issue) {
101
+ const tag = chalk_1.default.green(`[${issue.result.rule}]`);
102
+ // Truncate the message for the resolved line
103
+ const shortMsg = issue.result.message.length > 60
104
+ ? issue.result.message.substring(0, 60) + '...'
105
+ : issue.result.message;
106
+ console.log(`\n ${chalk_1.default.green('✓')} ${tag} ${chalk_1.default.green('Resolved:')} ${chalk_1.default.gray(shortMsg)}`);
107
+ }
108
+ /** Get count of currently active (unresolved) issues */
109
+ get activeIssueCount() {
110
+ let count = 0;
111
+ for (const issue of this.issues.values()) {
112
+ if (!issue.resolved)
113
+ count++;
114
+ }
115
+ return count;
116
+ }
117
+ /** Get all active (unresolved) issues with full detail */
118
+ getActiveIssues() {
119
+ const results = [];
120
+ for (const issue of this.issues.values()) {
121
+ if (!issue.resolved) {
122
+ results.push(issue.result);
123
+ }
124
+ }
125
+ return results;
126
+ }
127
+ /** Get all tracked issues (for session summary) */
128
+ getSummary() {
129
+ let active = 0;
130
+ let resolved = 0;
131
+ const byRule = new Map();
132
+ for (const issue of this.issues.values()) {
133
+ if (issue.resolved) {
134
+ resolved++;
135
+ }
136
+ else {
137
+ active++;
138
+ }
139
+ const count = byRule.get(issue.result.rule) || 0;
140
+ byRule.set(issue.result.rule, count + 1);
141
+ }
142
+ return { total: this.issues.size, active, resolved, byRule };
143
+ }
144
+ }
145
+ exports.AnalysisEngine = AnalysisEngine;
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ /**
3
+ * Schema drift detection for API routes.
4
+ *
5
+ * Infers a structural schema from JSON responses, stores baselines per
6
+ * route+method, and detects drift: removed fields, type changes, new fields,
7
+ * and null→non-null (or vice-versa) transitions.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.SchemaTracker = void 0;
14
+ exports.inferSchema = inferSchema;
15
+ exports.compareSchemas = compareSchemas;
16
+ const chalk_1 = __importDefault(require("chalk"));
17
+ // ─── Schema inference ───
18
+ function inferSchema(value) {
19
+ if (value === null || value === undefined) {
20
+ return { kind: 'null' };
21
+ }
22
+ if (typeof value === 'boolean')
23
+ return { kind: 'boolean' };
24
+ if (typeof value === 'number')
25
+ return { kind: 'number' };
26
+ if (typeof value === 'string')
27
+ return { kind: 'string' };
28
+ if (Array.isArray(value)) {
29
+ if (value.length === 0) {
30
+ return { kind: 'array', items: null };
31
+ }
32
+ // Infer from first element (representative sample)
33
+ return { kind: 'array', items: inferSchema(value[0]) };
34
+ }
35
+ if (typeof value === 'object') {
36
+ const fields = new Map();
37
+ for (const [key, val] of Object.entries(value)) {
38
+ fields.set(key, inferSchema(val));
39
+ }
40
+ return { kind: 'object', fields };
41
+ }
42
+ return { kind: 'string' }; // fallback
43
+ }
44
+ // ─── Schema comparison ───
45
+ function compareSchemas(baseline, current, path = '$') {
46
+ const diffs = [];
47
+ // Handle nullable wrapper
48
+ const baseKind = baseline.kind === 'nullable' ? baseline.inner.kind : baseline.kind;
49
+ const currKind = current.kind === 'nullable' ? current.inner.kind : current.kind;
50
+ const baseNode = baseline.kind === 'nullable' ? baseline.inner : baseline;
51
+ const currNode = current.kind === 'nullable' ? current.inner : current;
52
+ // Null status changes
53
+ const baseIsNullable = baseline.kind === 'null' || baseline.kind === 'nullable';
54
+ const currIsNullable = current.kind === 'null' || current.kind === 'nullable';
55
+ if (baseIsNullable !== currIsNullable && baseKind === currKind) {
56
+ if (baseIsNullable && !currIsNullable) {
57
+ diffs.push({
58
+ type: 'null_changed',
59
+ path,
60
+ detail: `was nullable, now always ${currKind}`,
61
+ });
62
+ }
63
+ else if (!baseIsNullable && currIsNullable) {
64
+ diffs.push({
65
+ type: 'null_changed',
66
+ path,
67
+ detail: `was ${baseKind}, now nullable`,
68
+ });
69
+ }
70
+ }
71
+ // Type change (different base kinds)
72
+ if (baseKind !== currKind && baseline.kind !== 'null' && current.kind !== 'null') {
73
+ diffs.push({
74
+ type: 'type_changed',
75
+ path,
76
+ detail: `was ${baseKind}, now ${currKind}`,
77
+ });
78
+ return diffs; // No point comparing children if types differ
79
+ }
80
+ // Object comparison
81
+ if (baseNode.kind === 'object' && currNode.kind === 'object') {
82
+ // Removed fields
83
+ for (const [key, baseFieldSchema] of baseNode.fields) {
84
+ if (!currNode.fields.has(key)) {
85
+ diffs.push({
86
+ type: 'removed',
87
+ path: `${path}.${key}`,
88
+ detail: `field removed (was ${schemaKindLabel(baseFieldSchema)})`,
89
+ });
90
+ }
91
+ else {
92
+ // Recurse into shared fields
93
+ const currFieldSchema = currNode.fields.get(key);
94
+ diffs.push(...compareSchemas(baseFieldSchema, currFieldSchema, `${path}.${key}`));
95
+ }
96
+ }
97
+ // New fields
98
+ for (const [key, currFieldSchema] of currNode.fields) {
99
+ if (!baseNode.fields.has(key)) {
100
+ diffs.push({
101
+ type: 'added',
102
+ path: `${path}.${key}`,
103
+ detail: `new field (${schemaKindLabel(currFieldSchema)})`,
104
+ });
105
+ }
106
+ }
107
+ }
108
+ // Array comparison — compare item schemas
109
+ if (baseNode.kind === 'array' && currNode.kind === 'array') {
110
+ if (baseNode.items && currNode.items) {
111
+ diffs.push(...compareSchemas(baseNode.items, currNode.items, `${path}[]`));
112
+ }
113
+ }
114
+ return diffs;
115
+ }
116
+ function schemaKindLabel(node) {
117
+ switch (node.kind) {
118
+ case 'object': return 'object';
119
+ case 'array': return node.items ? `${schemaKindLabel(node.items)}[]` : 'array';
120
+ case 'nullable': return `${schemaKindLabel(node.inner)}?`;
121
+ default: return node.kind;
122
+ }
123
+ }
124
+ // ─── Schema Tracker ───
125
+ class SchemaTracker {
126
+ /** Key: "METHOD route" (e.g. "GET /api/products") */
127
+ baselines = new Map();
128
+ /** Number of consecutive responses with new schema needed to auto-accept */
129
+ static AUTO_ACCEPT_COUNT = 3;
130
+ /**
131
+ * Process a response body from a route check.
132
+ * Only call for 2xx responses on API routes.
133
+ * Returns diffs if schema drift is detected, empty array otherwise.
134
+ */
135
+ processResponse(route, method, body) {
136
+ const key = `${method} ${route}`;
137
+ const currentSchema = inferSchema(body);
138
+ const existing = this.baselines.get(key);
139
+ if (!existing) {
140
+ // First response — store as baseline
141
+ this.baselines.set(key, {
142
+ schema: currentSchema,
143
+ consecutiveMatches: 1,
144
+ pendingSchema: null,
145
+ pendingMatches: 0,
146
+ });
147
+ return [];
148
+ }
149
+ // Compare against baseline
150
+ const diffs = compareSchemas(existing.schema, currentSchema);
151
+ if (diffs.length === 0) {
152
+ // Matches baseline — reset any pending schema
153
+ existing.consecutiveMatches++;
154
+ existing.pendingSchema = null;
155
+ existing.pendingMatches = 0;
156
+ return [];
157
+ }
158
+ // Schema differs from baseline
159
+ if (existing.pendingSchema) {
160
+ // Check if it matches the pending schema
161
+ const pendingDiffs = compareSchemas(existing.pendingSchema, currentSchema);
162
+ if (pendingDiffs.length === 0) {
163
+ existing.pendingMatches++;
164
+ if (existing.pendingMatches >= SchemaTracker.AUTO_ACCEPT_COUNT) {
165
+ // Auto-accept: the new schema has been seen enough times
166
+ existing.schema = existing.pendingSchema;
167
+ existing.consecutiveMatches = existing.pendingMatches;
168
+ existing.pendingSchema = null;
169
+ existing.pendingMatches = 0;
170
+ return []; // Silently accept
171
+ }
172
+ // Still pending — return diffs against original baseline
173
+ return diffs;
174
+ }
175
+ // Different from both baseline and pending — start new pending
176
+ existing.pendingSchema = currentSchema;
177
+ existing.pendingMatches = 1;
178
+ return diffs;
179
+ }
180
+ // First time seeing a different schema — start pending
181
+ existing.pendingSchema = currentSchema;
182
+ existing.pendingMatches = 1;
183
+ return diffs;
184
+ }
185
+ /** Reset the baseline for routes associated with a changed file */
186
+ resetForRoutes(routes) {
187
+ for (const route of routes) {
188
+ // Reset all methods for this route
189
+ for (const key of this.baselines.keys()) {
190
+ if (key.endsWith(` ${route}`)) {
191
+ this.baselines.delete(key);
192
+ }
193
+ }
194
+ }
195
+ }
196
+ /** Get active baselines (for API status) */
197
+ getBaselineCount() {
198
+ return this.baselines.size;
199
+ }
200
+ /** Print schema drift warnings */
201
+ static printDrift(route, method, diffs) {
202
+ if (diffs.length === 0)
203
+ return;
204
+ const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
205
+ console.log('');
206
+ console.log(chalk_1.default.yellow(` ${time} ⚠ ${method} ${route} — schema changed`));
207
+ let hasBreakingChange = false;
208
+ for (const diff of diffs) {
209
+ if (diff.type === 'removed') {
210
+ console.log(chalk_1.default.red(` Removed field: ${diff.path} (${diff.detail})`));
211
+ hasBreakingChange = true;
212
+ }
213
+ else if (diff.type === 'type_changed') {
214
+ console.log(chalk_1.default.yellow(` Type changed: ${diff.path} (${diff.detail})`));
215
+ hasBreakingChange = true;
216
+ }
217
+ else if (diff.type === 'added') {
218
+ console.log(chalk_1.default.cyan(` New field: ${diff.path} (${diff.detail})`));
219
+ }
220
+ else if (diff.type === 'null_changed') {
221
+ console.log(chalk_1.default.blue(` Null changed: ${diff.path} (${diff.detail})`));
222
+ hasBreakingChange = true;
223
+ }
224
+ }
225
+ if (hasBreakingChange) {
226
+ console.log(chalk_1.default.yellow(` ⚠ This may break frontend consumers of this API`));
227
+ }
228
+ }
229
+ }
230
+ exports.SchemaTracker = SchemaTracker;
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ /**
3
+ * Real-time trace analyzers for scanwarp dev.
4
+ *
5
+ * Each analyzer inspects spans from a single trace and returns
6
+ * zero or more analysis results (warnings, errors, suggestions).
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.defaultAnalyzers = exports.slowExternalCallDetector = exports.missingErrorHandlingDetector = exports.unhandledErrorDetector = exports.slowQueryDetector = exports.nPlusOneDetector = void 0;
10
+ // ─── Helpers ───
11
+ function getParentSpan(span, spans) {
12
+ if (!span.parent_span_id)
13
+ return undefined;
14
+ return spans.find((s) => s.span_id === span.parent_span_id);
15
+ }
16
+ function isDbSpan(span) {
17
+ return span.attributes['db.system'] !== undefined;
18
+ }
19
+ function isHttpClientSpan(span) {
20
+ return (span.kind === 'CLIENT' &&
21
+ (span.attributes['http.url'] !== undefined ||
22
+ span.attributes['url.full'] !== undefined));
23
+ }
24
+ function getHttpUrl(span) {
25
+ const url = span.attributes['http.url'] || span.attributes['url.full'];
26
+ return typeof url === 'string' ? url : String(url ?? '');
27
+ }
28
+ function getDbStatement(span) {
29
+ const stmt = span.attributes['db.statement'];
30
+ return typeof stmt === 'string' ? stmt : '';
31
+ }
32
+ /** Normalize a SQL statement by replacing literal values with ? placeholders */
33
+ function normalizeQuery(sql) {
34
+ return sql
35
+ .replace(/'[^']*'/g, '?') // string literals
36
+ .replace(/\b\d+\b/g, '?') // numeric literals
37
+ .replace(/\s+/g, ' ') // collapse whitespace
38
+ .trim();
39
+ }
40
+ function truncate(str, max) {
41
+ return str.length > max ? str.substring(0, max) + '...' : str;
42
+ }
43
+ function getErrorMessage(span) {
44
+ // Try exception event first
45
+ const exceptionEvent = span.events.find((e) => e.name === 'exception');
46
+ const exceptionMsg = exceptionEvent?.attributes?.['exception.message'];
47
+ if (typeof exceptionMsg === 'string')
48
+ return exceptionMsg;
49
+ // Fall back to status message
50
+ if (span.status_message)
51
+ return span.status_message;
52
+ return 'Unknown error';
53
+ }
54
+ // ─── Analyzer implementations ───
55
+ /**
56
+ * N+1 Query Detector
57
+ *
58
+ * Groups database spans by normalized query pattern within a single trace.
59
+ * If the same pattern appears 5+ times, flags it as an N+1.
60
+ */
61
+ exports.nPlusOneDetector = {
62
+ name: 'n-plus-one',
63
+ analyze(spans) {
64
+ const results = [];
65
+ // Group DB spans by normalized statement
66
+ const queryCounts = new Map();
67
+ for (const span of spans) {
68
+ if (!isDbSpan(span))
69
+ continue;
70
+ const stmt = getDbStatement(span);
71
+ if (!stmt)
72
+ continue;
73
+ const normalized = normalizeQuery(stmt);
74
+ const existing = queryCounts.get(normalized);
75
+ if (existing) {
76
+ existing.count++;
77
+ }
78
+ else {
79
+ queryCounts.set(normalized, { count: 1, raw: stmt });
80
+ }
81
+ }
82
+ for (const [pattern, { count, raw }] of queryCounts) {
83
+ if (count >= 5) {
84
+ results.push({
85
+ severity: 'warning',
86
+ rule: 'n-plus-one',
87
+ message: `N+1 query detected: '${truncate(pattern, 80)}' executed ${count} times`,
88
+ detail: `Full query: ${truncate(raw, 200)}`,
89
+ suggestion: 'Use a batch query or JOIN instead of querying in a loop',
90
+ });
91
+ }
92
+ }
93
+ return results;
94
+ },
95
+ };
96
+ /**
97
+ * Slow Database Query Detector
98
+ *
99
+ * Flags any database span over 500ms.
100
+ */
101
+ exports.slowQueryDetector = {
102
+ name: 'slow-query',
103
+ analyze(spans) {
104
+ const results = [];
105
+ for (const span of spans) {
106
+ if (!isDbSpan(span))
107
+ continue;
108
+ if (span.duration_ms <= 500)
109
+ continue;
110
+ const stmt = getDbStatement(span);
111
+ const dbSystem = String(span.attributes['db.system'] || 'database');
112
+ results.push({
113
+ severity: 'warning',
114
+ rule: 'slow-query',
115
+ message: `Slow ${dbSystem} query: ${truncate(stmt || span.operation_name, 100)} (${span.duration_ms}ms)`,
116
+ suggestion: 'Consider adding an index or optimizing this query',
117
+ });
118
+ }
119
+ return results;
120
+ },
121
+ };
122
+ /**
123
+ * Unhandled Error Detector
124
+ *
125
+ * Detects when a span has ERROR status and its parent also has ERROR status,
126
+ * indicating the error propagated up without being caught.
127
+ */
128
+ exports.unhandledErrorDetector = {
129
+ name: 'unhandled-error',
130
+ analyze(spans) {
131
+ const results = [];
132
+ for (const span of spans) {
133
+ if (span.status_code !== 'ERROR')
134
+ continue;
135
+ const parent = getParentSpan(span, spans);
136
+ if (!parent || parent.status_code !== 'ERROR')
137
+ continue;
138
+ // Skip HTTP client spans — they have their own analyzer
139
+ if (isHttpClientSpan(span))
140
+ continue;
141
+ const errorMsg = getErrorMessage(span);
142
+ results.push({
143
+ severity: 'error',
144
+ rule: 'unhandled-error',
145
+ message: `Unhandled error in ${span.operation_name}: ${truncate(errorMsg, 100)}`,
146
+ suggestion: 'Add error handling (try/catch) around this operation',
147
+ });
148
+ }
149
+ return results;
150
+ },
151
+ };
152
+ /**
153
+ * Missing Error Handling on External Calls
154
+ *
155
+ * When an HTTP client span fails and the parent span also has ERROR status,
156
+ * the external call failure wasn't handled gracefully.
157
+ */
158
+ exports.missingErrorHandlingDetector = {
159
+ name: 'missing-error-handling',
160
+ analyze(spans) {
161
+ const results = [];
162
+ for (const span of spans) {
163
+ if (!isHttpClientSpan(span))
164
+ continue;
165
+ if (span.status_code !== 'ERROR')
166
+ continue;
167
+ const parent = getParentSpan(span, spans);
168
+ if (!parent || parent.status_code !== 'ERROR')
169
+ continue;
170
+ const url = getHttpUrl(span);
171
+ // Extract just the host for cleaner output
172
+ let host = url;
173
+ try {
174
+ host = new URL(url).host;
175
+ }
176
+ catch {
177
+ // keep raw url
178
+ }
179
+ results.push({
180
+ severity: 'error',
181
+ rule: 'missing-error-handling',
182
+ message: `External API call to ${host} failed with no error handling`,
183
+ detail: `URL: ${truncate(url, 150)}`,
184
+ suggestion: 'Wrap this API call in try/catch and handle failures gracefully',
185
+ });
186
+ }
187
+ return results;
188
+ },
189
+ };
190
+ /**
191
+ * Slow External API Call
192
+ *
193
+ * Flags any HTTP client span over 2 seconds.
194
+ */
195
+ exports.slowExternalCallDetector = {
196
+ name: 'slow-external-call',
197
+ analyze(spans) {
198
+ const results = [];
199
+ for (const span of spans) {
200
+ if (!isHttpClientSpan(span))
201
+ continue;
202
+ if (span.duration_ms <= 2000)
203
+ continue;
204
+ const url = getHttpUrl(span);
205
+ let host = url;
206
+ try {
207
+ host = new URL(url).host;
208
+ }
209
+ catch {
210
+ // keep raw url
211
+ }
212
+ results.push({
213
+ severity: 'warning',
214
+ rule: 'slow-external-call',
215
+ message: `Slow external call to ${host}: ${span.duration_ms}ms`,
216
+ detail: `URL: ${truncate(url, 150)}`,
217
+ suggestion: 'Consider adding a timeout, caching the response, or making it async',
218
+ });
219
+ }
220
+ return results;
221
+ },
222
+ };
223
+ // ─── All built-in analyzers ───
224
+ exports.defaultAnalyzers = [
225
+ exports.nPlusOneDetector,
226
+ exports.slowQueryDetector,
227
+ exports.unhandledErrorDetector,
228
+ exports.missingErrorHandlingDetector,
229
+ exports.slowExternalCallDetector,
230
+ ];