brakit 0.7.4 → 0.7.6

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/api.js CHANGED
@@ -98,6 +98,8 @@ var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
98
98
  var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
99
99
  var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
100
100
  var INTERNAL_ID_SUFFIX = /Id$|_id$/;
101
+ var SELECT_STAR_RE = /^SELECT\s+\*/i;
102
+ var SELECT_DOT_STAR_RE = /\.\*\s+FROM/i;
101
103
  var RULE_HINTS = {
102
104
  "exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
103
105
  "token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
@@ -407,6 +409,58 @@ var corsCredentialsRule = {
407
409
  }
408
410
  };
409
411
 
412
+ // src/constants/thresholds.ts
413
+ var FLOW_GAP_MS = 5e3;
414
+ var SLOW_REQUEST_THRESHOLD_MS = 2e3;
415
+ var MIN_POLLING_SEQUENCE = 3;
416
+ var ENDPOINT_TRUNCATE_LENGTH = 12;
417
+ var N1_QUERY_THRESHOLD = 5;
418
+ var ERROR_RATE_THRESHOLD_PCT = 20;
419
+ var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
420
+ var MIN_REQUESTS_FOR_INSIGHT = 2;
421
+ var HIGH_QUERY_COUNT_PER_REQ = 5;
422
+ var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
423
+ var CROSS_ENDPOINT_PCT = 50;
424
+ var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
425
+ var REDUNDANT_QUERY_MIN_COUNT = 2;
426
+ var LARGE_RESPONSE_BYTES = 51200;
427
+ var HIGH_ROW_COUNT = 100;
428
+ var OVERFETCH_MIN_REQUESTS = 2;
429
+ var OVERFETCH_MIN_FIELDS = 8;
430
+ var OVERFETCH_MIN_INTERNAL_IDS = 2;
431
+ var OVERFETCH_NULL_RATIO = 0.3;
432
+ var REGRESSION_PCT_THRESHOLD = 50;
433
+ var REGRESSION_MIN_INCREASE_MS = 200;
434
+ var REGRESSION_MIN_REQUESTS = 5;
435
+ var QUERY_COUNT_REGRESSION_RATIO = 1.5;
436
+ var OVERFETCH_MANY_FIELDS = 12;
437
+ var OVERFETCH_UNWRAP_MIN_SIZE = 3;
438
+ var MAX_DUPLICATE_INSIGHTS = 3;
439
+
440
+ // src/utils/response.ts
441
+ function unwrapResponse(parsed) {
442
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
443
+ const obj = parsed;
444
+ const keys = Object.keys(obj);
445
+ if (keys.length > 3) return parsed;
446
+ let best = null;
447
+ let bestSize = 0;
448
+ for (const key of keys) {
449
+ const val = obj[key];
450
+ if (Array.isArray(val) && val.length > bestSize) {
451
+ best = val;
452
+ bestSize = val.length;
453
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
454
+ const size = Object.keys(val).length;
455
+ if (size > bestSize) {
456
+ best = val;
457
+ bestSize = size;
458
+ }
459
+ }
460
+ }
461
+ return best && bestSize >= OVERFETCH_UNWRAP_MIN_SIZE ? best : parsed;
462
+ }
463
+
410
464
  // src/analysis/rules/response-pii-leak.ts
411
465
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
412
466
  var FULL_RECORD_MIN_FIELDS = 5;
@@ -451,28 +505,6 @@ function hasInternalIds(obj) {
451
505
  }
452
506
  return false;
453
507
  }
454
- function unwrapResponse(parsed) {
455
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
456
- const obj = parsed;
457
- const keys = Object.keys(obj);
458
- if (keys.length > 3) return parsed;
459
- let best = null;
460
- let bestSize = 0;
461
- for (const key of keys) {
462
- const val = obj[key];
463
- if (Array.isArray(val) && val.length > bestSize) {
464
- best = val;
465
- bestSize = val.length;
466
- } else if (val && typeof val === "object" && !Array.isArray(val)) {
467
- const size = Object.keys(val).length;
468
- if (size > bestSize) {
469
- best = val;
470
- bestSize = size;
471
- }
472
- }
473
- }
474
- return best && bestSize >= 3 ? best : parsed;
475
- }
476
508
  function detectPII(method, reqBody, resBody) {
477
509
  const target = unwrapResponse(resBody);
478
510
  if (WRITE_METHODS.has(method) && reqBody && typeof reqBody === "object") {
@@ -598,27 +630,6 @@ var DASHBOARD_PREFIX = "/__brakit";
598
630
  var MAX_REQUEST_ENTRIES = 1e3;
599
631
  var MAX_TELEMETRY_ENTRIES = 1e3;
600
632
 
601
- // src/constants/thresholds.ts
602
- var FLOW_GAP_MS = 5e3;
603
- var SLOW_REQUEST_THRESHOLD_MS = 2e3;
604
- var MIN_POLLING_SEQUENCE = 3;
605
- var ENDPOINT_TRUNCATE_LENGTH = 12;
606
- var N1_QUERY_THRESHOLD = 5;
607
- var ERROR_RATE_THRESHOLD_PCT = 20;
608
- var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
609
- var MIN_REQUESTS_FOR_INSIGHT = 2;
610
- var HIGH_QUERY_COUNT_PER_REQ = 5;
611
- var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
612
- var CROSS_ENDPOINT_PCT = 50;
613
- var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
614
- var REDUNDANT_QUERY_MIN_COUNT = 2;
615
- var LARGE_RESPONSE_BYTES = 51200;
616
- var HIGH_ROW_COUNT = 100;
617
- var OVERFETCH_MIN_REQUESTS = 2;
618
- var OVERFETCH_MIN_FIELDS = 8;
619
- var OVERFETCH_MIN_INTERNAL_IDS = 2;
620
- var OVERFETCH_NULL_RATIO = 0.3;
621
-
622
633
  // src/utils/static-patterns.ts
623
634
  var STATIC_PATTERNS = [
624
635
  /^\/_next\//,
@@ -670,7 +681,7 @@ var RequestStore = class {
670
681
  responseHeaders: flattenHeaders(input.responseHeaders),
671
682
  responseBody: responseBodyStr,
672
683
  startedAt: input.startTime,
673
- durationMs: Math.round(performance.now() - input.startTime),
684
+ durationMs: Math.round((input.endTime ?? performance.now()) - input.startTime),
674
685
  responseSize: input.responseBody?.length ?? 0,
675
686
  isStatic: isStaticPath(path)
676
687
  };
@@ -760,14 +771,21 @@ var defaultQueryStore = new QueryStore();
760
771
  // src/store/metrics/metrics-store.ts
761
772
  import { randomUUID as randomUUID2 } from "crypto";
762
773
 
774
+ // src/utils/endpoint.ts
775
+ function getEndpointKey(method, path) {
776
+ return `${method} ${path}`;
777
+ }
778
+
763
779
  // src/store/metrics/persistence.ts
764
780
  import {
765
781
  readFileSync as readFileSync2,
766
782
  writeFileSync as writeFileSync2,
767
783
  mkdirSync as mkdirSync2,
768
784
  existsSync as existsSync2,
769
- unlinkSync
785
+ unlinkSync,
786
+ renameSync
770
787
  } from "fs";
788
+ import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
771
789
  import { resolve as resolve2 } from "path";
772
790
 
773
791
  // src/analysis/group.ts
@@ -1123,6 +1141,22 @@ function deriveFlowLabel(requests, sourcePage) {
1123
1141
  return trigger.label;
1124
1142
  }
1125
1143
 
1144
+ // src/utils/collections.ts
1145
+ function groupBy(items, keyFn) {
1146
+ const map = /* @__PURE__ */ new Map();
1147
+ for (const item of items) {
1148
+ const key = keyFn(item);
1149
+ if (key == null) continue;
1150
+ let arr = map.get(key);
1151
+ if (!arr) {
1152
+ arr = [];
1153
+ map.set(key, arr);
1154
+ }
1155
+ arr.push(item);
1156
+ }
1157
+ return map;
1158
+ }
1159
+
1126
1160
  // src/instrument/adapters/normalize.ts
1127
1161
  function normalizeSQL(sql) {
1128
1162
  if (!sql) return { op: "OTHER", table: "" };
@@ -1155,7 +1189,7 @@ function normalizeQueryParams(sql) {
1155
1189
  return n;
1156
1190
  }
1157
1191
 
1158
- // src/analysis/insights.ts
1192
+ // src/analysis/insights/query-helpers.ts
1159
1193
  function getQueryShape(q) {
1160
1194
  if (q.sql) return normalizeQueryParams(q.sql) ?? "";
1161
1195
  return `${q.operation ?? q.normalizedOp ?? "?"}:${q.model ?? q.table ?? ""}`;
@@ -1167,143 +1201,234 @@ function getQueryInfo(q) {
1167
1201
  table: q.table ?? q.model ?? ""
1168
1202
  };
1169
1203
  }
1170
- function formatDuration(ms) {
1171
- if (ms < 1e3) return `${ms}ms`;
1172
- return `${(ms / 1e3).toFixed(1)}s`;
1173
- }
1174
- function formatSize(bytes) {
1175
- if (bytes < 1024) return `${bytes}B`;
1176
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1177
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
1204
+
1205
+ // src/analysis/insights/prepare.ts
1206
+ function createEndpointGroup() {
1207
+ return {
1208
+ total: 0,
1209
+ errors: 0,
1210
+ totalDuration: 0,
1211
+ queryCount: 0,
1212
+ totalSize: 0,
1213
+ totalQueryTimeMs: 0,
1214
+ totalFetchTimeMs: 0,
1215
+ queryShapeDurations: /* @__PURE__ */ new Map()
1216
+ };
1178
1217
  }
1179
- function computeInsights(ctx) {
1180
- const insights = [];
1218
+ function prepareContext(ctx) {
1181
1219
  const nonStatic = ctx.requests.filter(
1182
1220
  (r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
1183
1221
  );
1184
- const queriesByReq = /* @__PURE__ */ new Map();
1185
- for (const q of ctx.queries) {
1186
- if (!q.parentRequestId) continue;
1187
- let arr = queriesByReq.get(q.parentRequestId);
1188
- if (!arr) {
1189
- arr = [];
1190
- queriesByReq.set(q.parentRequestId, arr);
1222
+ const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
1223
+ const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
1224
+ const reqById = new Map(nonStatic.map((r) => [r.id, r]));
1225
+ const endpointGroups = /* @__PURE__ */ new Map();
1226
+ for (const r of nonStatic) {
1227
+ const ep = getEndpointKey(r.method, r.path);
1228
+ let g = endpointGroups.get(ep);
1229
+ if (!g) {
1230
+ g = createEndpointGroup();
1231
+ endpointGroups.set(ep, g);
1191
1232
  }
1192
- arr.push(q);
1193
- }
1194
- const reqById = /* @__PURE__ */ new Map();
1195
- for (const r of nonStatic) reqById.set(r.id, r);
1196
- const n1Seen = /* @__PURE__ */ new Set();
1197
- for (const [reqId, reqQueries] of queriesByReq) {
1198
- const req = reqById.get(reqId);
1199
- if (!req) continue;
1200
- const endpoint = `${req.method} ${req.path}`;
1201
- const shapeGroups = /* @__PURE__ */ new Map();
1233
+ g.total++;
1234
+ if (r.statusCode >= 400) g.errors++;
1235
+ g.totalDuration += r.durationMs;
1236
+ g.totalSize += r.responseSize ?? 0;
1237
+ const reqQueries = queriesByReq.get(r.id) ?? [];
1238
+ g.queryCount += reqQueries.length;
1202
1239
  for (const q of reqQueries) {
1240
+ g.totalQueryTimeMs += q.durationMs;
1203
1241
  const shape = getQueryShape(q);
1204
- let group = shapeGroups.get(shape);
1205
- if (!group) {
1206
- group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: q };
1207
- shapeGroups.set(shape, group);
1242
+ const info = getQueryInfo(q);
1243
+ let sd = g.queryShapeDurations.get(shape);
1244
+ if (!sd) {
1245
+ sd = { totalMs: 0, count: 0, label: info.op + (info.table ? ` ${info.table}` : "") };
1246
+ g.queryShapeDurations.set(shape, sd);
1208
1247
  }
1209
- group.count++;
1210
- group.distinctSql.add(q.sql ?? shape);
1248
+ sd.totalMs += q.durationMs;
1249
+ sd.count++;
1211
1250
  }
1212
- for (const [, sg] of shapeGroups) {
1213
- if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
1214
- const info = getQueryInfo(sg.first);
1215
- const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
1216
- if (n1Seen.has(key)) continue;
1217
- n1Seen.add(key);
1218
- insights.push({
1219
- severity: "critical",
1220
- type: "n1",
1221
- title: "N+1 Query Pattern",
1222
- desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
1223
- hint: "This typically happens when fetching related data in a loop. Use a batch query, JOIN, or include/eager-load to fetch all records at once.",
1224
- nav: "queries"
1225
- });
1251
+ const reqFetches = fetchesByReq.get(r.id) ?? [];
1252
+ for (const f of reqFetches) {
1253
+ g.totalFetchTimeMs += f.durationMs;
1226
1254
  }
1227
1255
  }
1228
- const ceQueryMap = /* @__PURE__ */ new Map();
1229
- const ceAllEndpoints = /* @__PURE__ */ new Set();
1230
- for (const [reqId, reqQueries] of queriesByReq) {
1231
- const req = reqById.get(reqId);
1232
- if (!req) continue;
1233
- const endpoint = `${req.method} ${req.path}`;
1234
- ceAllEndpoints.add(endpoint);
1235
- const seenInReq = /* @__PURE__ */ new Set();
1236
- for (const q of reqQueries) {
1237
- const shape = getQueryShape(q);
1238
- let entry = ceQueryMap.get(shape);
1239
- if (!entry) {
1240
- entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
1241
- ceQueryMap.set(shape, entry);
1242
- }
1243
- entry.count++;
1244
- if (!seenInReq.has(shape)) {
1245
- seenInReq.add(shape);
1246
- entry.endpoints.add(endpoint);
1256
+ return {
1257
+ ...ctx,
1258
+ nonStatic,
1259
+ queriesByReq,
1260
+ fetchesByReq,
1261
+ reqById,
1262
+ endpointGroups
1263
+ };
1264
+ }
1265
+
1266
+ // src/analysis/insights/runner.ts
1267
+ var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
1268
+ var InsightRunner = class {
1269
+ rules = [];
1270
+ register(rule) {
1271
+ this.rules.push(rule);
1272
+ }
1273
+ run(ctx) {
1274
+ const prepared = prepareContext(ctx);
1275
+ const insights = [];
1276
+ for (const rule of this.rules) {
1277
+ try {
1278
+ insights.push(...rule.check(prepared));
1279
+ } catch {
1247
1280
  }
1248
1281
  }
1282
+ insights.sort(
1283
+ (a, b) => (SEVERITY_ORDER[a.severity] ?? 2) - (SEVERITY_ORDER[b.severity] ?? 2)
1284
+ );
1285
+ return insights;
1249
1286
  }
1250
- if (ceAllEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
1251
- for (const [, cem] of ceQueryMap) {
1252
- if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
1253
- if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
1254
- const pct = Math.round(cem.endpoints.size / ceAllEndpoints.size * 100);
1255
- if (pct < CROSS_ENDPOINT_PCT) continue;
1256
- const info = getQueryInfo(cem.first);
1257
- const label = info.op + (info.table ? ` ${info.table}` : "");
1258
- insights.push({
1259
- severity: "warning",
1260
- type: "cross-endpoint",
1261
- title: "Repeated Query Across Endpoints",
1262
- desc: `${label} runs on ${cem.endpoints.size} of ${ceAllEndpoints.size} endpoints (${pct}%).`,
1263
- hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
1264
- nav: "queries"
1265
- });
1287
+ };
1288
+
1289
+ // src/analysis/insights/rules/n1.ts
1290
+ var n1Rule = {
1291
+ id: "n1",
1292
+ check(ctx) {
1293
+ const insights = [];
1294
+ const seen = /* @__PURE__ */ new Set();
1295
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
1296
+ const req = ctx.reqById.get(reqId);
1297
+ if (!req) continue;
1298
+ const endpoint = getEndpointKey(req.method, req.path);
1299
+ const shapeGroups = /* @__PURE__ */ new Map();
1300
+ for (const q of reqQueries) {
1301
+ const shape = getQueryShape(q);
1302
+ let group = shapeGroups.get(shape);
1303
+ if (!group) {
1304
+ group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: q };
1305
+ shapeGroups.set(shape, group);
1306
+ }
1307
+ group.count++;
1308
+ group.distinctSql.add(q.sql ?? shape);
1309
+ }
1310
+ for (const [, sg] of shapeGroups) {
1311
+ if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
1312
+ const info = getQueryInfo(sg.first);
1313
+ const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
1314
+ if (seen.has(key)) continue;
1315
+ seen.add(key);
1316
+ insights.push({
1317
+ severity: "critical",
1318
+ type: "n1",
1319
+ title: "N+1 Query Pattern",
1320
+ desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
1321
+ hint: "This typically happens when fetching related data in a loop. Use a batch query, JOIN, or include/eager-load to fetch all records at once.",
1322
+ nav: "queries"
1323
+ });
1324
+ }
1266
1325
  }
1326
+ return insights;
1267
1327
  }
1268
- const rqSeen = /* @__PURE__ */ new Set();
1269
- for (const [reqId, reqQueries] of queriesByReq) {
1270
- const req = reqById.get(reqId);
1271
- if (!req) continue;
1272
- const endpoint = `${req.method} ${req.path}`;
1273
- const exact = /* @__PURE__ */ new Map();
1274
- for (const q of reqQueries) {
1275
- if (!q.sql) continue;
1276
- let entry = exact.get(q.sql);
1277
- if (!entry) {
1278
- entry = { count: 0, first: q };
1279
- exact.set(q.sql, entry);
1328
+ };
1329
+
1330
+ // src/analysis/insights/rules/cross-endpoint.ts
1331
+ var crossEndpointRule = {
1332
+ id: "cross-endpoint",
1333
+ check(ctx) {
1334
+ const insights = [];
1335
+ const queryMap = /* @__PURE__ */ new Map();
1336
+ const allEndpoints = /* @__PURE__ */ new Set();
1337
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
1338
+ const req = ctx.reqById.get(reqId);
1339
+ if (!req) continue;
1340
+ const endpoint = getEndpointKey(req.method, req.path);
1341
+ allEndpoints.add(endpoint);
1342
+ const seenInReq = /* @__PURE__ */ new Set();
1343
+ for (const q of reqQueries) {
1344
+ const shape = getQueryShape(q);
1345
+ let entry = queryMap.get(shape);
1346
+ if (!entry) {
1347
+ entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
1348
+ queryMap.set(shape, entry);
1349
+ }
1350
+ entry.count++;
1351
+ if (!seenInReq.has(shape)) {
1352
+ seenInReq.add(shape);
1353
+ entry.endpoints.add(endpoint);
1354
+ }
1280
1355
  }
1281
- entry.count++;
1282
1356
  }
1283
- for (const [, e] of exact) {
1284
- if (e.count < REDUNDANT_QUERY_MIN_COUNT) continue;
1285
- const info = getQueryInfo(e.first);
1286
- const label = info.op + (info.table ? ` ${info.table}` : "");
1287
- const dedupKey = `${endpoint}:${label}`;
1288
- if (rqSeen.has(dedupKey)) continue;
1289
- rqSeen.add(dedupKey);
1290
- insights.push({
1291
- severity: "warning",
1292
- type: "redundant-query",
1293
- title: "Redundant Query",
1294
- desc: `${label} runs ${e.count}x with identical params in ${endpoint}.`,
1295
- hint: "The exact same query with identical parameters runs multiple times in one request. Cache the first result or lift the query to a shared function.",
1296
- nav: "queries"
1297
- });
1357
+ if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
1358
+ for (const [, cem] of queryMap) {
1359
+ if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
1360
+ if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
1361
+ const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
1362
+ if (p < CROSS_ENDPOINT_PCT) continue;
1363
+ const info = getQueryInfo(cem.first);
1364
+ const label = info.op + (info.table ? ` ${info.table}` : "");
1365
+ insights.push({
1366
+ severity: "warning",
1367
+ type: "cross-endpoint",
1368
+ title: "Repeated Query Across Endpoints",
1369
+ desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
1370
+ hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
1371
+ nav: "queries"
1372
+ });
1373
+ }
1374
+ }
1375
+ return insights;
1376
+ }
1377
+ };
1378
+
1379
+ // src/analysis/insights/rules/redundant-query.ts
1380
+ var redundantQueryRule = {
1381
+ id: "redundant-query",
1382
+ check(ctx) {
1383
+ const insights = [];
1384
+ const seen = /* @__PURE__ */ new Set();
1385
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
1386
+ const req = ctx.reqById.get(reqId);
1387
+ if (!req) continue;
1388
+ const endpoint = getEndpointKey(req.method, req.path);
1389
+ const exact = /* @__PURE__ */ new Map();
1390
+ for (const q of reqQueries) {
1391
+ if (!q.sql) continue;
1392
+ let entry = exact.get(q.sql);
1393
+ if (!entry) {
1394
+ entry = { count: 0, first: q };
1395
+ exact.set(q.sql, entry);
1396
+ }
1397
+ entry.count++;
1398
+ }
1399
+ for (const [, e] of exact) {
1400
+ if (e.count < REDUNDANT_QUERY_MIN_COUNT) continue;
1401
+ const info = getQueryInfo(e.first);
1402
+ const label = info.op + (info.table ? ` ${info.table}` : "");
1403
+ const dedupKey = `${endpoint}:${label}`;
1404
+ if (seen.has(dedupKey)) continue;
1405
+ seen.add(dedupKey);
1406
+ insights.push({
1407
+ severity: "warning",
1408
+ type: "redundant-query",
1409
+ title: "Redundant Query",
1410
+ desc: `${label} runs ${e.count}x with identical params in ${endpoint}.`,
1411
+ hint: "The exact same query with identical parameters runs multiple times in one request. Cache the first result or lift the query to a shared function.",
1412
+ nav: "queries"
1413
+ });
1414
+ }
1298
1415
  }
1416
+ return insights;
1299
1417
  }
1300
- if (ctx.errors.length > 0) {
1301
- const errGroups = /* @__PURE__ */ new Map();
1418
+ };
1419
+
1420
+ // src/analysis/insights/rules/error.ts
1421
+ var errorRule = {
1422
+ id: "error",
1423
+ check(ctx) {
1424
+ if (ctx.errors.length === 0) return [];
1425
+ const insights = [];
1426
+ const groups = /* @__PURE__ */ new Map();
1302
1427
  for (const e of ctx.errors) {
1303
1428
  const name = e.name || "Error";
1304
- errGroups.set(name, (errGroups.get(name) ?? 0) + 1);
1429
+ groups.set(name, (groups.get(name) ?? 0) + 1);
1305
1430
  }
1306
- for (const [name, cnt] of errGroups) {
1431
+ for (const [name, cnt] of groups) {
1307
1432
  insights.push({
1308
1433
  severity: "critical",
1309
1434
  type: "error",
@@ -1313,235 +1438,367 @@ function computeInsights(ctx) {
1313
1438
  nav: "errors"
1314
1439
  });
1315
1440
  }
1441
+ return insights;
1316
1442
  }
1317
- const endpointGroups = /* @__PURE__ */ new Map();
1318
- for (const r of nonStatic) {
1319
- const ep = `${r.method} ${r.path}`;
1320
- let g = endpointGroups.get(ep);
1321
- if (!g) {
1322
- g = { total: 0, errors: 0, totalDuration: 0, queryCount: 0, totalSize: 0 };
1323
- endpointGroups.set(ep, g);
1443
+ };
1444
+
1445
+ // src/analysis/insights/rules/error-hotspot.ts
1446
+ var errorHotspotRule = {
1447
+ id: "error-hotspot",
1448
+ check(ctx) {
1449
+ const insights = [];
1450
+ for (const [ep, g] of ctx.endpointGroups) {
1451
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1452
+ const errorRate = Math.round(g.errors / g.total * 100);
1453
+ if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
1454
+ insights.push({
1455
+ severity: "critical",
1456
+ type: "error-hotspot",
1457
+ title: "Error Hotspot",
1458
+ desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
1459
+ hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
1460
+ nav: "requests"
1461
+ });
1462
+ }
1324
1463
  }
1325
- g.total++;
1326
- if (r.statusCode >= 400) g.errors++;
1327
- g.totalDuration += r.durationMs;
1328
- g.queryCount += (queriesByReq.get(r.id) ?? []).length;
1329
- g.totalSize += r.responseSize ?? 0;
1464
+ return insights;
1330
1465
  }
1331
- for (const [ep, g] of endpointGroups) {
1332
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1333
- const errorRate = Math.round(g.errors / g.total * 100);
1334
- if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
1466
+ };
1467
+
1468
+ // src/analysis/insights/rules/duplicate.ts
1469
+ var duplicateRule = {
1470
+ id: "duplicate",
1471
+ check(ctx) {
1472
+ const dupCounts = /* @__PURE__ */ new Map();
1473
+ const flowCount = /* @__PURE__ */ new Map();
1474
+ for (const flow of ctx.flows) {
1475
+ if (!flow.requests) continue;
1476
+ const seenInFlow = /* @__PURE__ */ new Set();
1477
+ for (const fr of flow.requests) {
1478
+ if (!fr.isDuplicate) continue;
1479
+ const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
1480
+ dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
1481
+ if (!seenInFlow.has(dupKey)) {
1482
+ seenInFlow.add(dupKey);
1483
+ flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
1484
+ }
1485
+ }
1486
+ }
1487
+ const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
1488
+ const insights = [];
1489
+ for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
1490
+ const d = dupEntries[i];
1335
1491
  insights.push({
1336
- severity: "critical",
1337
- type: "error-hotspot",
1338
- title: "Error Hotspot",
1339
- desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
1340
- hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
1341
- nav: "requests"
1492
+ severity: "warning",
1493
+ type: "duplicate",
1494
+ title: "Duplicate API Call",
1495
+ desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
1496
+ hint: "Multiple components independently fetch the same endpoint. Lift the fetch to a parent component, use a data cache, or deduplicate with React Query / SWR.",
1497
+ nav: "actions"
1342
1498
  });
1343
1499
  }
1500
+ return insights;
1344
1501
  }
1345
- const dupCounts = /* @__PURE__ */ new Map();
1346
- const flowCount = /* @__PURE__ */ new Map();
1347
- for (const flow of ctx.flows) {
1348
- if (!flow.requests) continue;
1349
- const seenInFlow = /* @__PURE__ */ new Set();
1350
- for (const fr of flow.requests) {
1351
- if (!fr.isDuplicate) continue;
1352
- const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
1353
- dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
1354
- if (!seenInFlow.has(dupKey)) {
1355
- seenInFlow.add(dupKey);
1356
- flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
1502
+ };
1503
+
1504
+ // src/utils/format.ts
1505
+ function formatDuration(ms) {
1506
+ if (ms < 1e3) return `${ms}ms`;
1507
+ return `${(ms / 1e3).toFixed(1)}s`;
1508
+ }
1509
+ function formatSize(bytes) {
1510
+ if (bytes < 1024) return `${bytes}B`;
1511
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1512
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
1513
+ }
1514
+ function pct(part, total) {
1515
+ return total > 0 ? Math.round(part / total * 100) : 0;
1516
+ }
1517
+
1518
+ // src/analysis/insights/rules/slow.ts
1519
+ var slowRule = {
1520
+ id: "slow",
1521
+ check(ctx) {
1522
+ const insights = [];
1523
+ for (const [ep, g] of ctx.endpointGroups) {
1524
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1525
+ const avgMs = Math.round(g.totalDuration / g.total);
1526
+ if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
1527
+ const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
1528
+ const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
1529
+ const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
1530
+ const parts = [];
1531
+ if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
1532
+ if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
1533
+ if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
1534
+ const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
1535
+ let detail;
1536
+ let slowestMs = 0;
1537
+ for (const [, sd] of g.queryShapeDurations) {
1538
+ const avgShapeMs = sd.totalMs / sd.count;
1539
+ if (avgShapeMs > slowestMs) {
1540
+ slowestMs = avgShapeMs;
1541
+ detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
1542
+ }
1357
1543
  }
1358
- }
1359
- }
1360
- const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
1361
- for (let i = 0; i < Math.min(dupEntries.length, 3); i++) {
1362
- const d = dupEntries[i];
1363
- insights.push({
1364
- severity: "warning",
1365
- type: "duplicate",
1366
- title: "Duplicate API Call",
1367
- desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
1368
- hint: "Multiple components independently fetch the same endpoint. Lift the fetch to a parent component, use a data cache, or deduplicate with React Query / SWR.",
1369
- nav: "actions"
1370
- });
1371
- }
1372
- for (const [ep, g] of endpointGroups) {
1373
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1374
- const avgMs = Math.round(g.totalDuration / g.total);
1375
- if (avgMs >= SLOW_ENDPOINT_THRESHOLD_MS) {
1376
1544
  insights.push({
1377
1545
  severity: "warning",
1378
1546
  type: "slow",
1379
1547
  title: "Slow Endpoint",
1380
- desc: `${ep} \u2014 avg ${formatDuration(avgMs)} across ${g.total} request${g.total !== 1 ? "s" : ""}`,
1381
- hint: "Consistently slow responses hurt user experience. Check the Queries tab to see if database queries are the bottleneck.",
1548
+ desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
1549
+ hint: avgQueryMs >= avgFetchMs && avgQueryMs >= avgAppMs ? "Most time is in database queries. Check the Queries tab for slow or redundant queries." : avgFetchMs >= avgQueryMs && avgFetchMs >= avgAppMs ? "Most time is in outbound HTTP calls. Check if upstream services are slow or if calls can be parallelized." : "Most time is in application code. Profile the handler for CPU-heavy operations or blocking calls.",
1550
+ detail,
1382
1551
  nav: "requests"
1383
1552
  });
1384
1553
  }
1554
+ return insights;
1555
+ }
1556
+ };
1557
+
1558
+ // src/analysis/insights/rules/query-heavy.ts
1559
+ var queryHeavyRule = {
1560
+ id: "query-heavy",
1561
+ check(ctx) {
1562
+ const insights = [];
1563
+ for (const [ep, g] of ctx.endpointGroups) {
1564
+ if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1565
+ const avgQueries = Math.round(g.queryCount / g.total);
1566
+ if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
1567
+ insights.push({
1568
+ severity: "warning",
1569
+ type: "query-heavy",
1570
+ title: "Query-Heavy Endpoint",
1571
+ desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
1572
+ hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
1573
+ nav: "queries"
1574
+ });
1575
+ }
1576
+ }
1577
+ return insights;
1385
1578
  }
1386
- for (const [ep, g] of endpointGroups) {
1387
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1388
- const avgQueries = Math.round(g.queryCount / g.total);
1389
- if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
1579
+ };
1580
+
1581
+ // src/analysis/insights/rules/select-star.ts
1582
+ var selectStarRule = {
1583
+ id: "select-star",
1584
+ check(ctx) {
1585
+ const seen = /* @__PURE__ */ new Map();
1586
+ for (const [, reqQueries] of ctx.queriesByReq) {
1587
+ for (const q of reqQueries) {
1588
+ if (!q.sql) continue;
1589
+ const isSelectStar = SELECT_STAR_RE.test(q.sql.trim()) || SELECT_DOT_STAR_RE.test(q.sql);
1590
+ if (!isSelectStar) continue;
1591
+ const info = getQueryInfo(q);
1592
+ const key = info.table || "unknown";
1593
+ seen.set(key, (seen.get(key) ?? 0) + 1);
1594
+ }
1595
+ }
1596
+ const insights = [];
1597
+ for (const [table, count] of seen) {
1598
+ if (count < OVERFETCH_MIN_REQUESTS) continue;
1390
1599
  insights.push({
1391
1600
  severity: "warning",
1392
- type: "query-heavy",
1393
- title: "Query-Heavy Endpoint",
1394
- desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
1395
- hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
1601
+ type: "select-star",
1602
+ title: "SELECT * Query",
1603
+ desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
1604
+ hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
1396
1605
  nav: "queries"
1397
1606
  });
1398
1607
  }
1608
+ return insights;
1399
1609
  }
1400
- const selectStarSeen = /* @__PURE__ */ new Map();
1401
- for (const [, reqQueries] of queriesByReq) {
1402
- for (const q of reqQueries) {
1403
- if (!q.sql) continue;
1404
- const isSelectStar = /^SELECT\s+\*/i.test(q.sql.trim()) || /\.\*\s+FROM/i.test(q.sql);
1405
- if (!isSelectStar) continue;
1406
- const info = getQueryInfo(q);
1407
- const key = info.table || "unknown";
1408
- selectStarSeen.set(key, (selectStarSeen.get(key) ?? 0) + 1);
1409
- }
1410
- }
1411
- for (const [table, count] of selectStarSeen) {
1412
- if (count < OVERFETCH_MIN_REQUESTS) continue;
1413
- insights.push({
1414
- severity: "warning",
1415
- type: "select-star",
1416
- title: "SELECT * Query",
1417
- desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
1418
- hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
1419
- nav: "queries"
1420
- });
1421
- }
1422
- const highRowSeen = /* @__PURE__ */ new Map();
1423
- for (const [, reqQueries] of queriesByReq) {
1424
- for (const q of reqQueries) {
1425
- if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
1426
- const info = getQueryInfo(q);
1427
- const key = `${info.op} ${info.table || "unknown"}`;
1428
- let entry = highRowSeen.get(key);
1429
- if (!entry) {
1430
- entry = { max: 0, count: 0 };
1431
- highRowSeen.set(key, entry);
1432
- }
1433
- entry.count++;
1434
- if (q.rowCount > entry.max) entry.max = q.rowCount;
1435
- }
1436
- }
1437
- for (const [key, hrs] of highRowSeen) {
1438
- if (hrs.count < OVERFETCH_MIN_REQUESTS) continue;
1439
- insights.push({
1440
- severity: "warning",
1441
- type: "high-rows",
1442
- title: "Large Result Set",
1443
- desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
1444
- hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
1445
- nav: "queries"
1446
- });
1447
- }
1448
- const overfetchSeen = /* @__PURE__ */ new Set();
1449
- for (const r of nonStatic) {
1450
- if (r.statusCode >= 400 || !r.responseBody) continue;
1451
- const ep = `${r.method} ${r.path}`;
1452
- if (overfetchSeen.has(ep)) continue;
1453
- let parsed;
1454
- try {
1455
- parsed = JSON.parse(r.responseBody);
1456
- } catch {
1457
- continue;
1458
- }
1459
- let target = parsed;
1460
- if (target && typeof target === "object" && !Array.isArray(target)) {
1461
- const topKeys = Object.keys(target);
1462
- if (topKeys.length <= 3) {
1463
- let best = null;
1464
- let bestSize = 0;
1465
- for (const k of topKeys) {
1466
- const val = target[k];
1467
- if (Array.isArray(val) && val.length > bestSize) {
1468
- best = val;
1469
- bestSize = val.length;
1470
- } else if (val && typeof val === "object" && !Array.isArray(val)) {
1471
- const size = Object.keys(val).length;
1472
- if (size > bestSize) {
1473
- best = val;
1474
- bestSize = size;
1475
- }
1476
- }
1610
+ };
1611
+
1612
+ // src/analysis/insights/rules/high-rows.ts
1613
+ var highRowsRule = {
1614
+ id: "high-rows",
1615
+ check(ctx) {
1616
+ const seen = /* @__PURE__ */ new Map();
1617
+ for (const [, reqQueries] of ctx.queriesByReq) {
1618
+ for (const q of reqQueries) {
1619
+ if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
1620
+ const info = getQueryInfo(q);
1621
+ const key = `${info.op} ${info.table || "unknown"}`;
1622
+ let entry = seen.get(key);
1623
+ if (!entry) {
1624
+ entry = { max: 0, count: 0 };
1625
+ seen.set(key, entry);
1477
1626
  }
1478
- if (best && bestSize >= 3) target = best;
1627
+ entry.count++;
1628
+ if (q.rowCount > entry.max) entry.max = q.rowCount;
1479
1629
  }
1480
1630
  }
1481
- const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
1482
- if (!inspectObj || typeof inspectObj !== "object" || Array.isArray(inspectObj)) continue;
1483
- const fields = Object.keys(inspectObj);
1484
- if (fields.length < OVERFETCH_MIN_FIELDS) continue;
1485
- let internalIdCount = 0;
1486
- let nullCount = 0;
1487
- for (const key of fields) {
1488
- if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
1489
- const val = inspectObj[key];
1490
- if (val === null || val === void 0) nullCount++;
1491
- }
1492
- const nullRatio = nullCount / fields.length;
1493
- const reasons = [];
1494
- if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
1495
- if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
1496
- if (fields.length >= OVERFETCH_MIN_FIELDS && reasons.length === 0 && fields.length >= 12) {
1497
- reasons.push(`${fields.length} fields returned`);
1498
- }
1499
- if (reasons.length > 0) {
1500
- overfetchSeen.add(ep);
1631
+ const insights = [];
1632
+ for (const [key, hrs] of seen) {
1633
+ if (hrs.count < OVERFETCH_MIN_REQUESTS) continue;
1501
1634
  insights.push({
1502
- severity: "info",
1503
- type: "response-overfetch",
1504
- title: "Response Overfetch",
1505
- desc: `${ep} \u2014 ${reasons.join(", ")}`,
1506
- hint: "This response returns more data than the client likely needs. Use a DTO or select only required fields to reduce payload size and avoid leaking internal structure.",
1507
- nav: "requests"
1635
+ severity: "warning",
1636
+ type: "high-rows",
1637
+ title: "Large Result Set",
1638
+ desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
1639
+ hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
1640
+ nav: "queries"
1508
1641
  });
1509
1642
  }
1643
+ return insights;
1510
1644
  }
1511
- for (const [ep, g] of endpointGroups) {
1512
- if (g.total < OVERFETCH_MIN_REQUESTS) continue;
1513
- const avgSize = Math.round(g.totalSize / g.total);
1514
- if (avgSize > LARGE_RESPONSE_BYTES) {
1515
- insights.push({
1516
- severity: "info",
1517
- type: "large-response",
1518
- title: "Large Response",
1519
- desc: `${ep} \u2014 avg ${formatSize(avgSize)} response`,
1520
- hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
1521
- nav: "requests"
1522
- });
1645
+ };
1646
+
1647
+ // src/analysis/insights/rules/response-overfetch.ts
1648
+ var responseOverfetchRule = {
1649
+ id: "response-overfetch",
1650
+ check(ctx) {
1651
+ const insights = [];
1652
+ const seen = /* @__PURE__ */ new Set();
1653
+ for (const r of ctx.nonStatic) {
1654
+ if (r.statusCode >= 400 || !r.responseBody) continue;
1655
+ const ep = getEndpointKey(r.method, r.path);
1656
+ if (seen.has(ep)) continue;
1657
+ let parsed;
1658
+ try {
1659
+ parsed = JSON.parse(r.responseBody);
1660
+ } catch {
1661
+ continue;
1662
+ }
1663
+ const target = unwrapResponse(parsed);
1664
+ const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
1665
+ if (!inspectObj || typeof inspectObj !== "object" || Array.isArray(inspectObj)) continue;
1666
+ const fields = Object.keys(inspectObj);
1667
+ if (fields.length < OVERFETCH_MIN_FIELDS) continue;
1668
+ let internalIdCount = 0;
1669
+ let nullCount = 0;
1670
+ for (const key of fields) {
1671
+ if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
1672
+ const val = inspectObj[key];
1673
+ if (val === null || val === void 0) nullCount++;
1674
+ }
1675
+ const nullRatio = nullCount / fields.length;
1676
+ const reasons = [];
1677
+ if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
1678
+ if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
1679
+ if (reasons.length === 0 && fields.length >= OVERFETCH_MANY_FIELDS) {
1680
+ reasons.push(`${fields.length} fields returned`);
1681
+ }
1682
+ if (reasons.length > 0) {
1683
+ seen.add(ep);
1684
+ insights.push({
1685
+ severity: "info",
1686
+ type: "response-overfetch",
1687
+ title: "Response Overfetch",
1688
+ desc: `${ep} \u2014 ${reasons.join(", ")}`,
1689
+ hint: "This response returns more data than the client likely needs. Use a DTO or select only required fields to reduce payload size and avoid leaking internal structure.",
1690
+ nav: "requests"
1691
+ });
1692
+ }
1523
1693
  }
1694
+ return insights;
1524
1695
  }
1525
- if (ctx.securityFindings) {
1526
- for (const f of ctx.securityFindings) {
1527
- insights.push({
1528
- severity: f.severity,
1529
- type: "security",
1530
- title: f.title,
1531
- desc: f.desc,
1532
- hint: f.hint,
1533
- nav: "security"
1534
- });
1696
+ };
1697
+
1698
+ // src/analysis/insights/rules/large-response.ts
1699
+ var largeResponseRule = {
1700
+ id: "large-response",
1701
+ check(ctx) {
1702
+ const insights = [];
1703
+ for (const [ep, g] of ctx.endpointGroups) {
1704
+ if (g.total < OVERFETCH_MIN_REQUESTS) continue;
1705
+ const avgSize = Math.round(g.totalSize / g.total);
1706
+ if (avgSize > LARGE_RESPONSE_BYTES) {
1707
+ insights.push({
1708
+ severity: "info",
1709
+ type: "large-response",
1710
+ title: "Large Response",
1711
+ desc: `${ep} \u2014 avg ${formatSize(avgSize)} response`,
1712
+ hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
1713
+ nav: "requests"
1714
+ });
1715
+ }
1535
1716
  }
1717
+ return insights;
1536
1718
  }
1537
- const severityOrder = { critical: 0, warning: 1, info: 2 };
1538
- insights.sort((a, b) => (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2));
1539
- return insights;
1719
+ };
1720
+
1721
+ // src/analysis/insights/rules/regression.ts
1722
+ var regressionRule = {
1723
+ id: "regression",
1724
+ check(ctx) {
1725
+ if (!ctx.previousMetrics || ctx.previousMetrics.length === 0) return [];
1726
+ const insights = [];
1727
+ for (const epMetrics of ctx.previousMetrics) {
1728
+ if (epMetrics.sessions.length < 2) continue;
1729
+ const prev = epMetrics.sessions[epMetrics.sessions.length - 2];
1730
+ const current = epMetrics.sessions[epMetrics.sessions.length - 1];
1731
+ if (prev.requestCount < REGRESSION_MIN_REQUESTS || current.requestCount < REGRESSION_MIN_REQUESTS) continue;
1732
+ const p95Increase = current.p95DurationMs - prev.p95DurationMs;
1733
+ const p95PctChange = prev.p95DurationMs > 0 ? Math.round(p95Increase / prev.p95DurationMs * 100) : 0;
1734
+ if (p95Increase >= REGRESSION_MIN_INCREASE_MS && p95PctChange >= REGRESSION_PCT_THRESHOLD) {
1735
+ insights.push({
1736
+ severity: "warning",
1737
+ type: "regression",
1738
+ title: "Performance Regression",
1739
+ desc: `${epMetrics.endpoint} p95 degraded ${formatDuration(prev.p95DurationMs)} \u2192 ${formatDuration(current.p95DurationMs)} (+${p95PctChange}%)`,
1740
+ hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing.",
1741
+ nav: "graph"
1742
+ });
1743
+ }
1744
+ if (prev.avgQueryCount > 0 && current.avgQueryCount > prev.avgQueryCount * QUERY_COUNT_REGRESSION_RATIO) {
1745
+ insights.push({
1746
+ severity: "warning",
1747
+ type: "regression",
1748
+ title: "Query Count Regression",
1749
+ desc: `${epMetrics.endpoint} queries/request increased ${prev.avgQueryCount} \u2192 ${current.avgQueryCount}`,
1750
+ hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations.",
1751
+ nav: "queries"
1752
+ });
1753
+ }
1754
+ }
1755
+ return insights;
1756
+ }
1757
+ };
1758
+
1759
+ // src/analysis/insights/rules/security.ts
1760
+ var securityRule = {
1761
+ id: "security",
1762
+ check(ctx) {
1763
+ if (!ctx.securityFindings) return [];
1764
+ return ctx.securityFindings.map((f) => ({
1765
+ severity: f.severity,
1766
+ type: "security",
1767
+ title: f.title,
1768
+ desc: f.desc,
1769
+ hint: f.hint,
1770
+ nav: "security"
1771
+ }));
1772
+ }
1773
+ };
1774
+
1775
+ // src/analysis/insights/index.ts
1776
+ function createDefaultInsightRunner() {
1777
+ const runner = new InsightRunner();
1778
+ runner.register(n1Rule);
1779
+ runner.register(crossEndpointRule);
1780
+ runner.register(redundantQueryRule);
1781
+ runner.register(errorRule);
1782
+ runner.register(errorHotspotRule);
1783
+ runner.register(duplicateRule);
1784
+ runner.register(slowRule);
1785
+ runner.register(queryHeavyRule);
1786
+ runner.register(selectStarRule);
1787
+ runner.register(highRowsRule);
1788
+ runner.register(responseOverfetchRule);
1789
+ runner.register(largeResponseRule);
1790
+ runner.register(regressionRule);
1791
+ runner.register(securityRule);
1792
+ return runner;
1793
+ }
1794
+ function computeInsights(ctx) {
1795
+ return createDefaultInsightRunner().run(ctx);
1540
1796
  }
1541
1797
 
1542
1798
  // src/analysis/engine.ts
1543
1799
  var AnalysisEngine = class {
1544
- constructor(debounceMs = 300) {
1800
+ constructor(metricsStore, debounceMs = 300) {
1801
+ this.metricsStore = metricsStore;
1545
1802
  this.debounceMs = debounceMs;
1546
1803
  this.scanner = createDefaultScanner();
1547
1804
  this.boundRequestListener = () => this.scheduleRecompute();
@@ -1599,6 +1856,7 @@ var AnalysisEngine = class {
1599
1856
  const queries = defaultQueryStore.getAll();
1600
1857
  const errors = defaultErrorStore.getAll();
1601
1858
  const logs = defaultLogStore.getAll();
1859
+ const fetches = defaultFetchStore.getAll();
1602
1860
  const flows = groupRequestsIntoFlows(requests);
1603
1861
  this.cachedFindings = this.scanner.scan({ requests, logs });
1604
1862
  this.cachedInsights = computeInsights({
@@ -1606,6 +1864,8 @@ var AnalysisEngine = class {
1606
1864
  queries,
1607
1865
  errors,
1608
1866
  flows,
1867
+ fetches,
1868
+ previousMetrics: this.metricsStore.getAll(),
1609
1869
  securityFindings: this.cachedFindings
1610
1870
  });
1611
1871
  for (const fn of this.listeners) {
@@ -1618,13 +1878,15 @@ var AnalysisEngine = class {
1618
1878
  };
1619
1879
 
1620
1880
  // src/index.ts
1621
- var VERSION = "0.7.4";
1881
+ var VERSION = "0.7.6";
1622
1882
  export {
1623
1883
  AdapterRegistry,
1624
1884
  AnalysisEngine,
1885
+ InsightRunner,
1625
1886
  SecurityScanner,
1626
1887
  VERSION,
1627
1888
  computeInsights,
1889
+ createDefaultInsightRunner,
1628
1890
  createDefaultScanner,
1629
1891
  detectProject
1630
1892
  };