brakit 0.8.7 → 9.0.0

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
@@ -10,7 +10,7 @@ import { createHash } from "crypto";
10
10
  import { homedir } from "os";
11
11
  import { resolve, join } from "path";
12
12
 
13
- // src/constants/limits.ts
13
+ // src/constants/config.ts
14
14
  var ANALYSIS_DEBOUNCE_MS = 300;
15
15
  var ISSUE_ID_HASH_LENGTH = 16;
16
16
  var ISSUES_DATA_VERSION = 2;
@@ -21,6 +21,38 @@ var FULL_RECORD_MIN_FIELDS = 8;
21
21
  var LIST_PII_MIN_ITEMS = 2;
22
22
  var MAX_OBJECT_SCAN_DEPTH = 5;
23
23
  var ISSUE_PRUNE_TTL_MS = 10 * 60 * 1e3;
24
+ var FLOW_GAP_MS = 5e3;
25
+ var SLOW_REQUEST_THRESHOLD_MS = 2e3;
26
+ var MIN_POLLING_SEQUENCE = 3;
27
+ var ENDPOINT_TRUNCATE_LENGTH = 12;
28
+ var N1_QUERY_THRESHOLD = 5;
29
+ var ERROR_RATE_THRESHOLD_PCT = 20;
30
+ var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
31
+ var MIN_REQUESTS_FOR_INSIGHT = 2;
32
+ var HIGH_QUERY_COUNT_PER_REQ = 5;
33
+ var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
34
+ var CROSS_ENDPOINT_PCT = 50;
35
+ var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
36
+ var REDUNDANT_QUERY_MIN_COUNT = 2;
37
+ var LARGE_RESPONSE_BYTES = 51200;
38
+ var HIGH_ROW_COUNT = 100;
39
+ var OVERFETCH_MIN_REQUESTS = 2;
40
+ var OVERFETCH_MIN_FIELDS = 8;
41
+ var OVERFETCH_MIN_INTERNAL_IDS = 2;
42
+ var OVERFETCH_NULL_RATIO = 0.3;
43
+ var REGRESSION_PCT_THRESHOLD = 50;
44
+ var REGRESSION_MIN_INCREASE_MS = 200;
45
+ var REGRESSION_MIN_REQUESTS = 5;
46
+ var QUERY_COUNT_REGRESSION_RATIO = 1.5;
47
+ var OVERFETCH_MANY_FIELDS = 12;
48
+ var OVERFETCH_UNWRAP_MIN_SIZE = 3;
49
+ var MAX_DUPLICATE_INSIGHTS = 3;
50
+ var INSIGHT_WINDOW_PER_ENDPOINT = 20;
51
+ var CLEAN_HITS_FOR_RESOLUTION = 5;
52
+ var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
53
+ var STRICT_MODE_MAX_GAP_MS = 2e3;
54
+ var ISSUES_FILE = "issues.json";
55
+ var ISSUES_FLUSH_INTERVAL_MS = 1e4;
24
56
 
25
57
  // src/utils/log.ts
26
58
  var PREFIX = "[brakit]";
@@ -42,7 +74,9 @@ function getErrorMessage(err) {
42
74
  return String(err);
43
75
  }
44
76
  function validateIssuesData(parsed) {
45
- if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.version === ISSUES_DATA_VERSION && Array.isArray(parsed.issues)) {
77
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
78
+ const obj = parsed;
79
+ if (obj.version === ISSUES_DATA_VERSION && Array.isArray(obj.issues)) {
46
80
  return parsed;
47
81
  }
48
82
  return null;
@@ -86,41 +120,6 @@ async function ensureGitignoreAsync(dir, entry) {
86
120
  }
87
121
  }
88
122
 
89
- // src/constants/metrics.ts
90
- var ISSUES_FILE = "issues.json";
91
- var ISSUES_FLUSH_INTERVAL_MS = 1e4;
92
-
93
- // src/constants/thresholds.ts
94
- var FLOW_GAP_MS = 5e3;
95
- var SLOW_REQUEST_THRESHOLD_MS = 2e3;
96
- var MIN_POLLING_SEQUENCE = 3;
97
- var ENDPOINT_TRUNCATE_LENGTH = 12;
98
- var N1_QUERY_THRESHOLD = 5;
99
- var ERROR_RATE_THRESHOLD_PCT = 20;
100
- var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
101
- var MIN_REQUESTS_FOR_INSIGHT = 2;
102
- var HIGH_QUERY_COUNT_PER_REQ = 5;
103
- var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
104
- var CROSS_ENDPOINT_PCT = 50;
105
- var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
106
- var REDUNDANT_QUERY_MIN_COUNT = 2;
107
- var LARGE_RESPONSE_BYTES = 51200;
108
- var HIGH_ROW_COUNT = 100;
109
- var OVERFETCH_MIN_REQUESTS = 2;
110
- var OVERFETCH_MIN_FIELDS = 8;
111
- var OVERFETCH_MIN_INTERNAL_IDS = 2;
112
- var OVERFETCH_NULL_RATIO = 0.3;
113
- var REGRESSION_PCT_THRESHOLD = 50;
114
- var REGRESSION_MIN_INCREASE_MS = 200;
115
- var REGRESSION_MIN_REQUESTS = 5;
116
- var QUERY_COUNT_REGRESSION_RATIO = 1.5;
117
- var OVERFETCH_MANY_FIELDS = 12;
118
- var OVERFETCH_UNWRAP_MIN_SIZE = 3;
119
- var MAX_DUPLICATE_INSIGHTS = 3;
120
- var INSIGHT_WINDOW_PER_ENDPOINT = 20;
121
- var CLEAN_HITS_FOR_RESOLUTION = 5;
122
- var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
123
-
124
123
  // src/utils/atomic-writer.ts
125
124
  import {
126
125
  writeFileSync as writeFileSync2,
@@ -467,11 +466,12 @@ function tryParseJson(body) {
467
466
  return null;
468
467
  }
469
468
  }
469
+ var MAX_WRAPPER_KEYS = 3;
470
470
  function unwrapResponse(parsed) {
471
471
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
472
472
  const obj = parsed;
473
473
  const keys = Object.keys(obj);
474
- if (keys.length > 3) return parsed;
474
+ if (keys.length > MAX_WRAPPER_KEYS) return parsed;
475
475
  let best = null;
476
476
  let bestSize = 0;
477
477
  for (const key of keys) {
@@ -529,27 +529,87 @@ function isRedirect(code) {
529
529
  return code >= 300 && code < 400;
530
530
  }
531
531
 
532
- // src/analysis/rules/exposed-secret.ts
533
- function findSecretKeys(obj, prefix, depth = 0) {
534
- const found = [];
535
- if (depth >= MAX_OBJECT_SCAN_DEPTH) return found;
536
- if (!obj || typeof obj !== "object") return found;
537
- if (Array.isArray(obj)) {
538
- for (let i = 0; i < Math.min(obj.length, SECRET_SCAN_ARRAY_LIMIT); i++) {
539
- found.push(...findSecretKeys(obj[i], prefix, depth + 1));
532
+ // src/utils/collections.ts
533
+ function getOrCreate(map, key, create) {
534
+ let value = map.get(key);
535
+ if (value === void 0) {
536
+ value = create();
537
+ map.set(key, value);
538
+ }
539
+ return value;
540
+ }
541
+ function deduplicateFindings(items, extract) {
542
+ const seen = /* @__PURE__ */ new Map();
543
+ const findings = [];
544
+ for (const item of items) {
545
+ const result = extract(item);
546
+ if (!result) continue;
547
+ const existing = seen.get(result.key);
548
+ if (existing) {
549
+ existing.count++;
550
+ continue;
551
+ }
552
+ seen.set(result.key, result.finding);
553
+ findings.push(result.finding);
554
+ }
555
+ return findings;
556
+ }
557
+ function groupBy(items, keyFn) {
558
+ const map = /* @__PURE__ */ new Map();
559
+ for (const item of items) {
560
+ const key = keyFn(item);
561
+ if (key == null) continue;
562
+ let arr = map.get(key);
563
+ if (!arr) {
564
+ arr = [];
565
+ map.set(key, arr);
540
566
  }
541
- return found;
567
+ arr.push(item);
542
568
  }
543
- for (const k of Object.keys(obj)) {
544
- const val = obj[k];
545
- if (SECRET_KEYS.test(k) && typeof val === "string" && val.length >= MIN_SECRET_VALUE_LENGTH && !MASKED_RE.test(val)) {
546
- found.push(k);
569
+ return map;
570
+ }
571
+
572
+ // src/utils/object-scan.ts
573
+ var DEFAULTS = {
574
+ maxDepth: MAX_OBJECT_SCAN_DEPTH,
575
+ arrayLimit: SECRET_SCAN_ARRAY_LIMIT
576
+ };
577
+ function walkObject(obj, visitor, options) {
578
+ const opts = { ...DEFAULTS, ...options };
579
+ walk(obj, visitor, opts, 0);
580
+ }
581
+ function walk(obj, visitor, opts, depth) {
582
+ if (depth >= opts.maxDepth) return;
583
+ if (!obj || typeof obj !== "object") return;
584
+ if (Array.isArray(obj)) {
585
+ for (let i = 0; i < Math.min(obj.length, opts.arrayLimit); i++) {
586
+ walk(obj[i], visitor, opts, depth + 1);
547
587
  }
588
+ return;
589
+ }
590
+ for (const key of Object.keys(obj)) {
591
+ const val = obj[key];
592
+ visitor(key, val, depth);
548
593
  if (typeof val === "object" && val !== null) {
549
- found.push(...findSecretKeys(val, prefix + k + ".", depth + 1));
594
+ walk(val, visitor, opts, depth + 1);
550
595
  }
551
596
  }
552
- return found;
597
+ }
598
+ function collectFromObject(obj, match, options) {
599
+ const results = [];
600
+ walkObject(obj, (key, value) => {
601
+ const result = match(key, value);
602
+ if (result !== null) results.push(result);
603
+ }, options);
604
+ return results;
605
+ }
606
+
607
+ // src/analysis/rules/auth-rules.ts
608
+ function findSecretKeys(obj) {
609
+ return collectFromObject(
610
+ obj,
611
+ (key, val) => SECRET_KEYS.test(key) && typeof val === "string" && val.length >= MIN_SECRET_VALUE_LENGTH && !MASKED_RE.test(val) ? key : null
612
+ );
553
613
  }
554
614
  var exposedSecretRule = {
555
615
  id: "exposed-secret",
@@ -557,50 +617,38 @@ var exposedSecretRule = {
557
617
  name: "Exposed Secret in Response",
558
618
  hint: RULE_HINTS["exposed-secret"],
559
619
  check(ctx) {
560
- const findings = [];
561
- const seen = /* @__PURE__ */ new Map();
562
- for (const r of ctx.requests) {
563
- if (isErrorStatus(r.statusCode)) continue;
564
- const parsed = ctx.parsedBodies.response.get(r.id);
565
- if (!parsed) continue;
566
- const keys = findSecretKeys(parsed, "");
567
- if (keys.length === 0) continue;
568
- const ep = `${r.method} ${r.path}`;
569
- const dedupKey = `${ep}:${keys.sort().join(",")}`;
570
- const existing = seen.get(dedupKey);
571
- if (existing) {
572
- existing.count++;
573
- continue;
574
- }
575
- const finding = {
576
- severity: "critical",
577
- rule: "exposed-secret",
578
- title: "Exposed Secret in Response",
579
- desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
580
- hint: this.hint,
581
- endpoint: ep,
582
- count: 1
620
+ return deduplicateFindings(ctx.requests, (request) => {
621
+ if (isErrorStatus(request.statusCode)) return null;
622
+ const parsed = ctx.parsedBodies.response.get(request.id);
623
+ if (!parsed) return null;
624
+ const keys = findSecretKeys(parsed);
625
+ if (keys.length === 0) return null;
626
+ const ep = `${request.method} ${request.path}`;
627
+ return {
628
+ key: `${ep}:${keys.sort().join(",")}`,
629
+ finding: {
630
+ severity: "critical",
631
+ rule: "exposed-secret",
632
+ title: "Exposed Secret in Response",
633
+ desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
634
+ hint: this.hint,
635
+ endpoint: ep,
636
+ count: 1
637
+ }
583
638
  };
584
- seen.set(dedupKey, finding);
585
- findings.push(finding);
586
- }
587
- return findings;
639
+ });
588
640
  }
589
641
  };
590
-
591
- // src/analysis/rules/token-in-url.ts
592
642
  var tokenInUrlRule = {
593
643
  id: "token-in-url",
594
644
  severity: "critical",
595
645
  name: "Auth Token in URL",
596
646
  hint: RULE_HINTS["token-in-url"],
597
647
  check(ctx) {
598
- const findings = [];
599
- const seen = /* @__PURE__ */ new Map();
600
- for (const r of ctx.requests) {
601
- const qIdx = r.url.indexOf("?");
602
- if (qIdx === -1) continue;
603
- const params = r.url.substring(qIdx + 1).split("&");
648
+ return deduplicateFindings(ctx.requests, (request) => {
649
+ const qIdx = request.url.indexOf("?");
650
+ if (qIdx === -1) return null;
651
+ const params = request.url.substring(qIdx + 1).split("&");
604
652
  const flagged = [];
605
653
  for (const param of params) {
606
654
  const [name, ...rest] = param.split("=");
@@ -610,65 +658,124 @@ var tokenInUrlRule = {
610
658
  flagged.push(name);
611
659
  }
612
660
  }
613
- if (flagged.length === 0) continue;
614
- const ep = `${r.method} ${r.path}`;
615
- const dedupKey = `${ep}:${flagged.sort().join(",")}`;
616
- const existing = seen.get(dedupKey);
617
- if (existing) {
618
- existing.count++;
619
- continue;
661
+ if (flagged.length === 0) return null;
662
+ const ep = `${request.method} ${request.path}`;
663
+ return {
664
+ key: `${ep}:${flagged.sort().join(",")}`,
665
+ finding: {
666
+ severity: "critical",
667
+ rule: "token-in-url",
668
+ title: "Auth Token in URL",
669
+ desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
670
+ hint: this.hint,
671
+ endpoint: ep,
672
+ count: 1
673
+ }
674
+ };
675
+ });
676
+ }
677
+ };
678
+ function isFrameworkResponse(request) {
679
+ if (isRedirect(request.statusCode)) return true;
680
+ if (request.path?.startsWith("/__")) return true;
681
+ if (request.responseHeaders?.["x-middleware-rewrite"]) return true;
682
+ return false;
683
+ }
684
+ var insecureCookieRule = {
685
+ id: "insecure-cookie",
686
+ severity: "warning",
687
+ name: "Insecure Cookie",
688
+ hint: RULE_HINTS["insecure-cookie"],
689
+ check(ctx) {
690
+ const cookieEntries = [];
691
+ for (const request of ctx.requests) {
692
+ if (!request.responseHeaders) continue;
693
+ if (isFrameworkResponse(request)) continue;
694
+ const setCookie = request.responseHeaders["set-cookie"];
695
+ if (!setCookie) continue;
696
+ const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
697
+ for (const cookie of cookies) {
698
+ cookieEntries.push({ cookie });
620
699
  }
621
- const finding = {
622
- severity: "critical",
623
- rule: "token-in-url",
624
- title: "Auth Token in URL",
625
- desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
700
+ }
701
+ return deduplicateFindings(cookieEntries, ({ cookie }) => {
702
+ const cookieName = cookie.trim().split("=")[0].trim();
703
+ const lower = cookie.toLowerCase();
704
+ const issues = [];
705
+ if (!lower.includes("httponly")) issues.push("HttpOnly");
706
+ if (!lower.includes("samesite")) issues.push("SameSite");
707
+ if (issues.length === 0) return null;
708
+ return {
709
+ key: `${cookieName}:${issues.join(",")}`,
710
+ finding: {
711
+ severity: "warning",
712
+ rule: "insecure-cookie",
713
+ title: "Insecure Cookie",
714
+ desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
715
+ hint: this.hint,
716
+ endpoint: cookieName,
717
+ count: 1
718
+ }
719
+ };
720
+ });
721
+ }
722
+ };
723
+ var corsCredentialsRule = {
724
+ id: "cors-credentials",
725
+ severity: "warning",
726
+ name: "CORS Credentials with Wildcard",
727
+ hint: RULE_HINTS["cors-credentials"],
728
+ check(ctx) {
729
+ const findings = [];
730
+ const seen = /* @__PURE__ */ new Set();
731
+ for (const request of ctx.requests) {
732
+ if (!request.responseHeaders) continue;
733
+ const origin = request.responseHeaders["access-control-allow-origin"];
734
+ const creds = request.responseHeaders["access-control-allow-credentials"];
735
+ if (origin !== "*" || creds !== "true") continue;
736
+ const ep = `${request.method} ${request.path}`;
737
+ if (seen.has(ep)) continue;
738
+ seen.add(ep);
739
+ findings.push({
740
+ severity: "warning",
741
+ rule: "cors-credentials",
742
+ title: "CORS Credentials with Wildcard",
743
+ desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
626
744
  hint: this.hint,
627
745
  endpoint: ep,
628
746
  count: 1
629
- };
630
- seen.set(dedupKey, finding);
631
- findings.push(finding);
747
+ });
632
748
  }
633
749
  return findings;
634
750
  }
635
751
  };
636
752
 
637
- // src/analysis/rules/stack-trace-leak.ts
753
+ // src/analysis/rules/data-rules.ts
638
754
  var stackTraceLeakRule = {
639
755
  id: "stack-trace-leak",
640
756
  severity: "critical",
641
757
  name: "Stack Trace Leaked to Client",
642
758
  hint: RULE_HINTS["stack-trace-leak"],
643
759
  check(ctx) {
644
- const findings = [];
645
- const seen = /* @__PURE__ */ new Map();
646
- for (const r of ctx.requests) {
647
- if (!r.responseBody) continue;
648
- if (!STACK_TRACE_RE.test(r.responseBody)) continue;
649
- const ep = `${r.method} ${r.path}`;
650
- const existing = seen.get(ep);
651
- if (existing) {
652
- existing.count++;
653
- continue;
654
- }
655
- const finding = {
656
- severity: "critical",
657
- rule: "stack-trace-leak",
658
- title: "Stack Trace Leaked to Client",
659
- desc: `${ep} \u2014 response exposes internal stack trace`,
660
- hint: this.hint,
661
- endpoint: ep,
662
- count: 1
760
+ return deduplicateFindings(ctx.requests, (request) => {
761
+ if (!request.responseBody) return null;
762
+ if (!STACK_TRACE_RE.test(request.responseBody)) return null;
763
+ const ep = `${request.method} ${request.path}`;
764
+ return {
765
+ key: ep,
766
+ finding: {
767
+ severity: "critical",
768
+ rule: "stack-trace-leak",
769
+ title: "Stack Trace Leaked to Client",
770
+ desc: `${ep} \u2014 response exposes internal stack trace`,
771
+ hint: this.hint,
772
+ endpoint: ep,
773
+ count: 1
774
+ }
663
775
  };
664
- seen.set(ep, finding);
665
- findings.push(finding);
666
- }
667
- return findings;
776
+ });
668
777
  }
669
778
  };
670
-
671
- // src/analysis/rules/error-info-leak.ts
672
779
  var CRITICAL_PATTERNS = [
673
780
  { re: DB_CONN_RE, label: "database connection string" },
674
781
  { re: SQL_FRAGMENT_RE, label: "SQL query fragment" },
@@ -680,90 +787,34 @@ var errorInfoLeakRule = {
680
787
  name: "Sensitive Data in Error Response",
681
788
  hint: RULE_HINTS["error-info-leak"],
682
789
  check(ctx) {
683
- const findings = [];
684
- const seen = /* @__PURE__ */ new Map();
685
- for (const r of ctx.requests) {
686
- if (r.statusCode < 400) continue;
687
- if (!r.responseBody) continue;
688
- if (r.responseHeaders["x-nextjs-error"] || r.responseHeaders["x-nextjs-matched-path"]) continue;
689
- const ep = `${r.method} ${r.path}`;
690
- for (const p of CRITICAL_PATTERNS) {
691
- if (!p.re.test(r.responseBody)) continue;
692
- const dedupKey = `${ep}:${p.label}`;
693
- const existing = seen.get(dedupKey);
694
- if (existing) {
695
- existing.count++;
696
- continue;
790
+ const entries = [];
791
+ for (const request of ctx.requests) {
792
+ if (request.statusCode < 400) continue;
793
+ if (!request.responseBody) continue;
794
+ if (request.responseHeaders["x-nextjs-error"] || request.responseHeaders["x-nextjs-matched-path"]) continue;
795
+ const ep = `${request.method} ${request.path}`;
796
+ for (const pattern of CRITICAL_PATTERNS) {
797
+ if (pattern.re.test(request.responseBody)) {
798
+ entries.push({ ep, pattern, body: request.responseBody });
697
799
  }
698
- const finding = {
800
+ }
801
+ }
802
+ return deduplicateFindings(entries, ({ ep, pattern }) => {
803
+ return {
804
+ key: `${ep}:${pattern.label}`,
805
+ finding: {
699
806
  severity: "critical",
700
807
  rule: "error-info-leak",
701
808
  title: "Sensitive Data in Error Response",
702
- desc: `${ep} \u2014 error response exposes ${p.label}`,
809
+ desc: `${ep} \u2014 error response exposes ${pattern.label}`,
703
810
  hint: this.hint,
704
811
  endpoint: ep,
705
812
  count: 1
706
- };
707
- seen.set(dedupKey, finding);
708
- findings.push(finding);
709
- }
710
- }
711
- return findings;
712
- }
713
- };
714
-
715
- // src/analysis/rules/insecure-cookie.ts
716
- function isFrameworkResponse(r) {
717
- if (isRedirect(r.statusCode)) return true;
718
- if (r.path?.startsWith("/__")) return true;
719
- if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
720
- return false;
721
- }
722
- var insecureCookieRule = {
723
- id: "insecure-cookie",
724
- severity: "warning",
725
- name: "Insecure Cookie",
726
- hint: RULE_HINTS["insecure-cookie"],
727
- check(ctx) {
728
- const findings = [];
729
- const seen = /* @__PURE__ */ new Map();
730
- for (const r of ctx.requests) {
731
- if (!r.responseHeaders) continue;
732
- if (isFrameworkResponse(r)) continue;
733
- const setCookie = r.responseHeaders["set-cookie"];
734
- if (!setCookie) continue;
735
- const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
736
- for (const cookie of cookies) {
737
- const cookieName = cookie.trim().split("=")[0].trim();
738
- const lower = cookie.toLowerCase();
739
- const issues = [];
740
- if (!lower.includes("httponly")) issues.push("HttpOnly");
741
- if (!lower.includes("samesite")) issues.push("SameSite");
742
- if (issues.length === 0) continue;
743
- const dedupKey = `${cookieName}:${issues.join(",")}`;
744
- const existing = seen.get(dedupKey);
745
- if (existing) {
746
- existing.count++;
747
- continue;
748
813
  }
749
- const finding = {
750
- severity: "warning",
751
- rule: "insecure-cookie",
752
- title: "Insecure Cookie",
753
- desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
754
- hint: this.hint,
755
- endpoint: cookieName,
756
- count: 1
757
- };
758
- seen.set(dedupKey, finding);
759
- findings.push(finding);
760
- }
761
- }
762
- return findings;
814
+ };
815
+ });
763
816
  }
764
817
  };
765
-
766
- // src/analysis/rules/sensitive-logs.ts
767
818
  var sensitiveLogsRule = {
768
819
  id: "sensitive-logs",
769
820
  severity: "warning",
@@ -788,58 +839,13 @@ var sensitiveLogsRule = {
788
839
  }];
789
840
  }
790
841
  };
791
-
792
- // src/analysis/rules/cors-credentials.ts
793
- var corsCredentialsRule = {
794
- id: "cors-credentials",
795
- severity: "warning",
796
- name: "CORS Credentials with Wildcard",
797
- hint: RULE_HINTS["cors-credentials"],
798
- check(ctx) {
799
- const findings = [];
800
- const seen = /* @__PURE__ */ new Set();
801
- for (const r of ctx.requests) {
802
- if (!r.responseHeaders) continue;
803
- const origin = r.responseHeaders["access-control-allow-origin"];
804
- const creds = r.responseHeaders["access-control-allow-credentials"];
805
- if (origin !== "*" || creds !== "true") continue;
806
- const ep = `${r.method} ${r.path}`;
807
- if (seen.has(ep)) continue;
808
- seen.add(ep);
809
- findings.push({
810
- severity: "warning",
811
- rule: "cors-credentials",
812
- title: "CORS Credentials with Wildcard",
813
- desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
814
- hint: this.hint,
815
- endpoint: ep,
816
- count: 1
817
- });
818
- }
819
- return findings;
820
- }
821
- };
822
-
823
- // src/analysis/rules/response-pii-leak.ts
824
842
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
825
- function findEmails(obj, depth = 0) {
826
- const emails = [];
827
- if (depth >= MAX_OBJECT_SCAN_DEPTH) return emails;
828
- if (!obj || typeof obj !== "object") return emails;
829
- if (Array.isArray(obj)) {
830
- for (let i = 0; i < Math.min(obj.length, PII_SCAN_ARRAY_LIMIT); i++) {
831
- emails.push(...findEmails(obj[i], depth + 1));
832
- }
833
- return emails;
834
- }
835
- for (const v of Object.values(obj)) {
836
- if (typeof v === "string" && EMAIL_RE.test(v)) {
837
- emails.push(v);
838
- } else if (typeof v === "object" && v !== null) {
839
- emails.push(...findEmails(v, depth + 1));
840
- }
841
- }
842
- return emails;
843
+ function findEmails(obj) {
844
+ return collectFromObject(
845
+ obj,
846
+ (_key, val) => typeof val === "string" && EMAIL_RE.test(val) ? val : null,
847
+ { arrayLimit: PII_SCAN_ARRAY_LIMIT }
848
+ );
843
849
  }
844
850
  function topLevelFieldCount(obj) {
845
851
  if (Array.isArray(obj)) {
@@ -924,35 +930,28 @@ var responsePiiLeakRule = {
924
930
  name: "PII Leak in Response",
925
931
  hint: RULE_HINTS["response-pii-leak"],
926
932
  check(ctx) {
927
- const findings = [];
928
- const seen = /* @__PURE__ */ new Map();
929
- for (const r of ctx.requests) {
930
- if (isErrorStatus(r.statusCode)) continue;
931
- if (SELF_SERVICE_PATH.test(r.path)) continue;
932
- const resJson = ctx.parsedBodies.response.get(r.id);
933
- if (!resJson) continue;
934
- const reqJson = ctx.parsedBodies.request.get(r.id) ?? null;
935
- const detection = detectPII(r.method, reqJson, resJson);
936
- if (!detection) continue;
937
- const ep = `${r.method} ${r.path}`;
938
- const existing = seen.get(ep);
939
- if (existing) {
940
- existing.count++;
941
- continue;
942
- }
943
- const finding = {
944
- severity: "warning",
945
- rule: "response-pii-leak",
946
- title: "PII Leak in Response",
947
- desc: `${ep} \u2014 exposes PII in response`,
948
- hint: `Detection: ${REASON_LABELS[detection.reason]}. ${this.hint}`,
949
- endpoint: ep,
950
- count: 1
933
+ return deduplicateFindings(ctx.requests, (request) => {
934
+ if (isErrorStatus(request.statusCode)) return null;
935
+ if (SELF_SERVICE_PATH.test(request.path)) return null;
936
+ const resJson = ctx.parsedBodies.response.get(request.id);
937
+ if (!resJson) return null;
938
+ const reqJson = ctx.parsedBodies.request.get(request.id) ?? null;
939
+ const detection = detectPII(request.method, reqJson, resJson);
940
+ if (!detection) return null;
941
+ const ep = `${request.method} ${request.path}`;
942
+ return {
943
+ key: ep,
944
+ finding: {
945
+ severity: "warning",
946
+ rule: "response-pii-leak",
947
+ title: "PII Leak in Response",
948
+ desc: `${ep} \u2014 exposes PII in response`,
949
+ hint: `Detection: ${REASON_LABELS[detection.reason]}. ${this.hint}`,
950
+ endpoint: ep,
951
+ count: 1
952
+ }
951
953
  };
952
- seen.set(ep, finding);
953
- findings.push(finding);
954
- }
955
- return findings;
954
+ });
956
955
  }
957
956
  };
958
957
 
@@ -960,14 +959,14 @@ var responsePiiLeakRule = {
960
959
  function buildBodyCache(requests) {
961
960
  const response = /* @__PURE__ */ new Map();
962
961
  const request = /* @__PURE__ */ new Map();
963
- for (const r of requests) {
964
- if (r.responseBody) {
965
- const parsed = tryParseJson(r.responseBody);
966
- if (parsed != null) response.set(r.id, parsed);
962
+ for (const req of requests) {
963
+ if (req.responseBody) {
964
+ const parsed = tryParseJson(req.responseBody);
965
+ if (parsed != null) response.set(req.id, parsed);
967
966
  }
968
- if (r.requestBody) {
969
- const parsed = tryParseJson(r.requestBody);
970
- if (parsed != null) request.set(r.id, parsed);
967
+ if (req.requestBody) {
968
+ const parsed = tryParseJson(req.requestBody);
969
+ if (parsed != null) request.set(req.id, parsed);
971
970
  }
972
971
  }
973
972
  return { response, request };
@@ -988,7 +987,8 @@ var SecurityScanner = class {
988
987
  for (const rule of this.rules) {
989
988
  try {
990
989
  findings.push(...rule.check(ctx));
991
- } catch {
990
+ } catch (e) {
991
+ brakitDebug(`rule ${rule.id} failed: ${getErrorMessage(e)}`);
992
992
  }
993
993
  }
994
994
  return findings;
@@ -1027,7 +1027,7 @@ var SubscriptionBag = class {
1027
1027
  // src/analysis/group.ts
1028
1028
  import { randomUUID } from "crypto";
1029
1029
 
1030
- // src/constants/routes.ts
1030
+ // src/constants/labels.ts
1031
1031
  var DASHBOARD_PREFIX = "/__brakit";
1032
1032
  var DASHBOARD_API_REQUESTS = `${DASHBOARD_PREFIX}/api/requests`;
1033
1033
  var DASHBOARD_API_EVENTS = `${DASHBOARD_PREFIX}/api/events`;
@@ -1059,13 +1059,40 @@ var VALID_TABS_TUPLE = [
1059
1059
  ];
1060
1060
  var VALID_TABS = new Set(VALID_TABS_TUPLE);
1061
1061
 
1062
- // src/constants/network.ts
1062
+ // src/constants/features.ts
1063
1063
  var RECOVERY_WINDOW_MS = 5 * 60 * 1e3;
1064
1064
 
1065
+ // src/utils/endpoint.ts
1066
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1067
+ var NUMERIC_ID_RE = /^\d+$/;
1068
+ var HEX_HASH_RE = /^[0-9a-f]{12,}$/i;
1069
+ var ALPHA_TOKEN_RE = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/;
1070
+ function isDynamicSegment(segment) {
1071
+ return UUID_RE.test(segment) || NUMERIC_ID_RE.test(segment) || HEX_HASH_RE.test(segment) || ALPHA_TOKEN_RE.test(segment);
1072
+ }
1073
+ var DYNAMIC_SEGMENT_PLACEHOLDER = ":id";
1074
+ function normalizePath(path) {
1075
+ const qIdx = path.indexOf("?");
1076
+ const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
1077
+ return pathname.split("/").map((seg) => seg && isDynamicSegment(seg) ? DYNAMIC_SEGMENT_PLACEHOLDER : seg).join("/");
1078
+ }
1079
+ function getEndpointKey(method, path) {
1080
+ return `${method} ${normalizePath(path)}`;
1081
+ }
1082
+ var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
1083
+ function extractEndpointFromDesc(desc) {
1084
+ return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
1085
+ }
1086
+ function stripQueryString(path) {
1087
+ const i = path.indexOf("?");
1088
+ return i === -1 ? path : path.slice(0, i);
1089
+ }
1090
+
1065
1091
  // src/analysis/categorize.ts
1066
1092
  function detectCategory(req) {
1067
1093
  const { method, url, statusCode, responseHeaders } = req;
1068
1094
  if (req.isStatic) return "static";
1095
+ if (req.isHealthCheck) return "health-check";
1069
1096
  if (statusCode === 307 && (url.includes("__clerk_handshake") || url.includes("__clerk_db_jwt"))) {
1070
1097
  return "auth-handshake";
1071
1098
  }
@@ -1217,20 +1244,42 @@ function capitalize(s) {
1217
1244
  }
1218
1245
 
1219
1246
  // src/analysis/transforms.ts
1220
- function markDuplicates(requests) {
1247
+ var DUPLICATE_CATEGORIES = /* @__PURE__ */ new Set(["data-fetch", "auth-check"]);
1248
+ function isDuplicateCandidate(req) {
1249
+ return DUPLICATE_CATEGORIES.has(req.category);
1250
+ }
1251
+ function buildRequestKey(req) {
1252
+ return `${req.method} ${stripQueryString(getEffectivePath(req))}`;
1253
+ }
1254
+ function isStrictModePattern(requests, counts) {
1255
+ if (counts.size === 0 || ![...counts.values()].every((c) => c === 2)) {
1256
+ return false;
1257
+ }
1258
+ const firstByKey = /* @__PURE__ */ new Map();
1259
+ for (const req of requests) {
1260
+ if (!isDuplicateCandidate(req)) continue;
1261
+ const key = buildRequestKey(req);
1262
+ const first = firstByKey.get(key);
1263
+ if (!first) {
1264
+ firstByKey.set(key, req);
1265
+ } else if (Math.abs(req.startedAt - first.startedAt) > STRICT_MODE_MAX_GAP_MS) {
1266
+ return false;
1267
+ }
1268
+ }
1269
+ return true;
1270
+ }
1271
+ function flagDuplicateRequests(requests) {
1221
1272
  const counts = /* @__PURE__ */ new Map();
1222
1273
  for (const req of requests) {
1223
- if (req.category !== "data-fetch" && req.category !== "auth-check")
1224
- continue;
1225
- const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
1274
+ if (!isDuplicateCandidate(req)) continue;
1275
+ const key = buildRequestKey(req);
1226
1276
  counts.set(key, (counts.get(key) ?? 0) + 1);
1227
1277
  }
1228
- const isStrictMode = counts.size > 0 && [...counts.values()].every((c) => c === 2);
1278
+ const isStrictMode = isStrictModePattern(requests, counts);
1229
1279
  const seen = /* @__PURE__ */ new Set();
1230
1280
  for (const req of requests) {
1231
- if (req.category !== "data-fetch" && req.category !== "auth-check")
1232
- continue;
1233
- const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
1281
+ if (!isDuplicateCandidate(req)) continue;
1282
+ const key = buildRequestKey(req);
1234
1283
  if (seen.has(key)) {
1235
1284
  if (isStrictMode) {
1236
1285
  req.isStrictModeDupe = true;
@@ -1242,20 +1291,20 @@ function markDuplicates(requests) {
1242
1291
  }
1243
1292
  }
1244
1293
  }
1245
- function collapsePolling(requests) {
1294
+ function mergePollingSequences(requests) {
1246
1295
  const result = [];
1247
1296
  let i = 0;
1248
1297
  while (i < requests.length) {
1249
1298
  const current = requests[i];
1250
- const currentEffective = getEffectivePath(current).split("?")[0];
1299
+ const currentEffective = stripQueryString(getEffectivePath(current));
1251
1300
  if (current.method === "GET" && current.category === "data-fetch") {
1252
- let j = i + 1;
1253
- while (j < requests.length && requests[j].method === "GET" && getEffectivePath(requests[j]).split("?")[0] === currentEffective) {
1254
- j++;
1301
+ let nextIndex = i + 1;
1302
+ while (nextIndex < requests.length && requests[nextIndex].method === "GET" && stripQueryString(getEffectivePath(requests[nextIndex])) === currentEffective) {
1303
+ nextIndex++;
1255
1304
  }
1256
- const count = j - i;
1305
+ const count = nextIndex - i;
1257
1306
  if (count >= MIN_POLLING_SEQUENCE) {
1258
- const last = requests[j - 1];
1307
+ const last = requests[nextIndex - 1];
1259
1308
  const pollingDuration = last.startedAt + last.durationMs - current.startedAt;
1260
1309
  const endpointName = prettifyEndpoint(currentEffective);
1261
1310
  result.push({
@@ -1266,7 +1315,7 @@ function collapsePolling(requests) {
1266
1315
  pollingDurationMs: pollingDuration,
1267
1316
  isDuplicate: false
1268
1317
  });
1269
- i = j;
1318
+ i = nextIndex;
1270
1319
  continue;
1271
1320
  }
1272
1321
  }
@@ -1279,18 +1328,18 @@ function formatDurationLabel(ms) {
1279
1328
  if (ms < 1e3) return `${ms}ms`;
1280
1329
  return `${(ms / 1e3).toFixed(1)}s`;
1281
1330
  }
1282
- function detectWarnings(requests) {
1331
+ function collectRequestWarnings(requests) {
1283
1332
  const warnings = [];
1284
1333
  const duplicateCount = requests.filter((r) => r.isDuplicate).length;
1285
1334
  if (duplicateCount > 0) {
1286
1335
  const unique = new Set(
1287
- requests.filter((r) => r.isDuplicate).map((r) => `${r.method} ${getEffectivePath(r).split("?")[0]}`)
1336
+ requests.filter((r) => r.isDuplicate).map((r) => buildRequestKey(r))
1288
1337
  );
1289
1338
  const endpoints = unique.size;
1290
1339
  const sameData = requests.filter((r) => r.isDuplicate).every((r) => {
1291
- const key = `${r.method} ${getEffectivePath(r).split("?")[0]}`;
1340
+ const key = buildRequestKey(r);
1292
1341
  const first = requests.find(
1293
- (o) => !o.isDuplicate && `${o.method} ${getEffectivePath(o).split("?")[0]}` === key
1342
+ (o) => !o.isDuplicate && buildRequestKey(o) === key
1294
1343
  );
1295
1344
  return first && first.responseBody === r.responseBody;
1296
1345
  });
@@ -1313,6 +1362,14 @@ function detectWarnings(requests) {
1313
1362
  }
1314
1363
 
1315
1364
  // src/analysis/group.ts
1365
+ function shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, startedAt) {
1366
+ if (currentRequests.length === 0) return false;
1367
+ const sourcePage = labeled.sourcePage;
1368
+ const isNewPage = sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
1369
+ const isTimeGap = startedAt - lastEndTime > FLOW_GAP_MS;
1370
+ const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
1371
+ return isNewPage || isTimeGap || isPageLoad;
1372
+ }
1316
1373
  function groupRequestsIntoFlows(requests) {
1317
1374
  if (requests.length === 0) return [];
1318
1375
  const flows = [];
@@ -1323,17 +1380,12 @@ function groupRequestsIntoFlows(requests) {
1323
1380
  if (req.path.startsWith(DASHBOARD_PREFIX)) continue;
1324
1381
  const labeled = labelRequest(req);
1325
1382
  if (labeled.category === "static") continue;
1326
- const sourcePage = labeled.sourcePage;
1327
- const gap = currentRequests.length > 0 ? req.startedAt - lastEndTime : 0;
1328
- const isNewPage = currentRequests.length > 0 && sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
1329
- const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
1330
- const isTimeGap = currentRequests.length > 0 && gap > FLOW_GAP_MS;
1331
- if (currentRequests.length > 0 && (isNewPage || isTimeGap || isPageLoad)) {
1383
+ if (shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, req.startedAt)) {
1332
1384
  flows.push(buildFlow(currentRequests));
1333
1385
  currentRequests = [];
1334
1386
  }
1335
1387
  currentRequests.push(labeled);
1336
- currentSourcePage = sourcePage ?? currentSourcePage;
1388
+ currentSourcePage = labeled.sourcePage ?? currentSourcePage;
1337
1389
  lastEndTime = Math.max(lastEndTime, req.startedAt + req.durationMs);
1338
1390
  }
1339
1391
  if (currentRequests.length > 0) {
@@ -1342,8 +1394,8 @@ function groupRequestsIntoFlows(requests) {
1342
1394
  return flows;
1343
1395
  }
1344
1396
  function buildFlow(rawRequests) {
1345
- markDuplicates(rawRequests);
1346
- const requests = collapsePolling(rawRequests);
1397
+ flagDuplicateRequests(rawRequests);
1398
+ const requests = mergePollingSequences(rawRequests);
1347
1399
  const first = requests[0];
1348
1400
  const startTime = first.startedAt;
1349
1401
  const endTime = Math.max(
@@ -1362,7 +1414,7 @@ function buildFlow(rawRequests) {
1362
1414
  startTime,
1363
1415
  totalDurationMs: Math.round(endTime - startTime),
1364
1416
  hasErrors: requests.some((r) => isErrorStatus(r.statusCode)),
1365
- warnings: detectWarnings(rawRequests),
1417
+ warnings: collectRequestWarnings(rawRequests),
1366
1418
  sourcePage,
1367
1419
  redundancyPct
1368
1420
  };
@@ -1374,20 +1426,20 @@ function getDominantSourcePage(requests) {
1374
1426
  counts.set(req.sourcePage, (counts.get(req.sourcePage) ?? 0) + 1);
1375
1427
  }
1376
1428
  }
1377
- let best = "";
1378
- let bestCount = 0;
1429
+ let mostCommonPage = "";
1430
+ let highestCount = 0;
1379
1431
  for (const [page, count] of counts) {
1380
- if (count > bestCount) {
1381
- best = page;
1382
- bestCount = count;
1432
+ if (count > highestCount) {
1433
+ mostCommonPage = page;
1434
+ highestCount = count;
1383
1435
  }
1384
1436
  }
1385
- return best || requests[0]?.path?.split("?")[0] || "/";
1437
+ return mostCommonPage || (requests[0]?.path ? stripQueryString(requests[0].path) : "") || "/";
1386
1438
  }
1387
1439
  function deriveFlowLabel(requests, sourcePage) {
1388
1440
  const trigger = requests.find((r) => r.category === "api-call") ?? requests.find((r) => r.category === "server-action") ?? requests.find((r) => r.category === "page-load") ?? requests.find((r) => r.category === "navigation") ?? requests.find((r) => r.category === "data-fetch") ?? requests[0];
1389
1441
  if (trigger.category === "page-load" || trigger.category === "navigation") {
1390
- const pageName = prettifyPageName(trigger.path.split("?")[0]);
1442
+ const pageName = prettifyPageName(stripQueryString(trigger.path));
1391
1443
  return `${pageName} Page`;
1392
1444
  }
1393
1445
  if (trigger.category === "api-call") {
@@ -1412,67 +1464,23 @@ function deriveFlowLabel(requests, sourcePage) {
1412
1464
  return trigger.label;
1413
1465
  }
1414
1466
 
1415
- // src/utils/collections.ts
1416
- function groupBy(items, keyFn) {
1417
- const map = /* @__PURE__ */ new Map();
1418
- for (const item of items) {
1419
- const key = keyFn(item);
1420
- if (key == null) continue;
1421
- let arr = map.get(key);
1422
- if (!arr) {
1423
- arr = [];
1424
- map.set(key, arr);
1425
- }
1426
- arr.push(item);
1427
- }
1428
- return map;
1429
- }
1430
-
1431
- // src/utils/endpoint.ts
1432
- var DYNAMIC_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^\d+$|^[0-9a-f]{12,}$|^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/i;
1433
- function normalizePath(path) {
1434
- const qIdx = path.indexOf("?");
1435
- const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
1436
- return pathname.split("/").map((seg) => seg && DYNAMIC_SEGMENT_RE.test(seg) ? ":id" : seg).join("/");
1437
- }
1438
- function getEndpointKey(method, path) {
1439
- return `${method} ${normalizePath(path)}`;
1440
- }
1441
- var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
1442
- function extractEndpointFromDesc(desc) {
1443
- return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
1444
- }
1445
-
1446
1467
  // src/instrument/adapters/normalize.ts
1468
+ var VALID_OPS = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE"]);
1469
+ var TABLE_RE = /(?:FROM|INTO|UPDATE)\s+(?:"?\w+"?\.)?"?(\w+)"?/i;
1447
1470
  function normalizeSQL(sql) {
1448
1471
  if (!sql) return { op: "OTHER", table: "" };
1449
1472
  const trimmed = sql.trim();
1450
- const op = trimmed.split(/\s+/)[0].toUpperCase();
1451
- if (/SELECT\s+COUNT/i.test(trimmed)) {
1452
- const match = trimmed.match(/FROM\s+"?\w+"?\."?(\w+)"?/i);
1453
- return { op: "SELECT", table: match?.[1] ?? "" };
1454
- }
1455
- const tableMatch = trimmed.match(/(?:FROM|INTO|UPDATE)\s+"?\w+"?\."?(\w+)"?/i);
1456
- const table = tableMatch?.[1] ?? "";
1457
- switch (op) {
1458
- case "SELECT":
1459
- return { op: "SELECT", table };
1460
- case "INSERT":
1461
- return { op: "INSERT", table };
1462
- case "UPDATE":
1463
- return { op: "UPDATE", table };
1464
- case "DELETE":
1465
- return { op: "DELETE", table };
1466
- default:
1467
- return { op: "OTHER", table };
1468
- }
1469
- }
1473
+ const keyword = trimmed.split(/\s+/, 1)[0].toUpperCase();
1474
+ const op = VALID_OPS.has(keyword) ? keyword : "OTHER";
1475
+ const table = trimmed.match(TABLE_RE)?.[1] ?? "";
1476
+ return { op, table };
1477
+ }
1478
+ var SQL_PARAM_MARKER = /\$\d+/g;
1479
+ var SQL_STRING_LITERAL = /'[^']*'/g;
1480
+ var SQL_NUMBER_LITERAL = /\b\d+(\.\d+)?\b/g;
1470
1481
  function normalizeQueryParams(sql) {
1471
1482
  if (!sql) return null;
1472
- let n = sql.replace(/'[^']*'/g, "?");
1473
- n = n.replace(/\b\d+(\.\d+)?\b/g, "?");
1474
- n = n.replace(/\$\d+/g, "?");
1475
- return n;
1483
+ return sql.replace(SQL_PARAM_MARKER, "?").replace(SQL_STRING_LITERAL, "?").replace(SQL_NUMBER_LITERAL, "?");
1476
1484
  }
1477
1485
 
1478
1486
  // src/analysis/insights/query-helpers.ts
@@ -1489,7 +1497,7 @@ function getQueryInfo(q) {
1489
1497
  }
1490
1498
 
1491
1499
  // src/analysis/insights/prepare.ts
1492
- function createEndpointGroup() {
1500
+ function emptyEndpointGroup() {
1493
1501
  return {
1494
1502
  total: 0,
1495
1503
  errors: 0,
@@ -1501,16 +1509,12 @@ function createEndpointGroup() {
1501
1509
  queryShapeDurations: /* @__PURE__ */ new Map()
1502
1510
  };
1503
1511
  }
1504
- function windowByEndpoint(requests) {
1512
+ function keepRecentPerEndpoint(requests) {
1505
1513
  const byEndpoint = /* @__PURE__ */ new Map();
1506
- for (const r of requests) {
1507
- const ep = getEndpointKey(r.method, r.path);
1508
- let list = byEndpoint.get(ep);
1509
- if (!list) {
1510
- list = [];
1511
- byEndpoint.set(ep, list);
1512
- }
1513
- list.push(r);
1514
+ for (const request of requests) {
1515
+ const endpointKey = getEndpointKey(request.method, request.path);
1516
+ const list = getOrCreate(byEndpoint, endpointKey, () => []);
1517
+ list.push(request);
1514
1518
  }
1515
1519
  const windowed = [];
1516
1520
  for (const [, reqs] of byEndpoint) {
@@ -1518,54 +1522,67 @@ function windowByEndpoint(requests) {
1518
1522
  }
1519
1523
  return windowed;
1520
1524
  }
1525
+ function filterUserRequests(requests) {
1526
+ return requests.filter(
1527
+ (request) => !request.isStatic && !request.isHealthCheck && (!request.path || !request.path.startsWith(DASHBOARD_PREFIX))
1528
+ );
1529
+ }
1521
1530
  function extractActiveEndpoints(requests) {
1522
1531
  const endpoints = /* @__PURE__ */ new Set();
1523
- for (const r of requests) {
1524
- if (!r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))) {
1525
- endpoints.add(getEndpointKey(r.method, r.path));
1526
- }
1532
+ for (const request of filterUserRequests(requests)) {
1533
+ endpoints.add(getEndpointKey(request.method, request.path));
1527
1534
  }
1528
1535
  return endpoints;
1529
1536
  }
1530
- function prepareContext(ctx) {
1531
- const nonStatic = ctx.requests.filter(
1532
- (r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
1533
- );
1534
- const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
1535
- const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
1536
- const reqById = new Map(nonStatic.map((r) => [r.id, r]));
1537
- const recent = windowByEndpoint(nonStatic);
1537
+ function aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq) {
1538
1538
  const endpointGroups = /* @__PURE__ */ new Map();
1539
- for (const r of recent) {
1540
- const ep = getEndpointKey(r.method, r.path);
1541
- let g = endpointGroups.get(ep);
1542
- if (!g) {
1543
- g = createEndpointGroup();
1544
- endpointGroups.set(ep, g);
1545
- }
1546
- g.total++;
1547
- if (isErrorStatus(r.statusCode)) g.errors++;
1548
- g.totalDuration += r.durationMs;
1549
- g.totalSize += r.responseSize ?? 0;
1550
- const reqQueries = queriesByReq.get(r.id) ?? [];
1551
- g.queryCount += reqQueries.length;
1552
- for (const q of reqQueries) {
1553
- g.totalQueryTimeMs += q.durationMs;
1554
- const shape = getQueryShape(q);
1555
- const info = getQueryInfo(q);
1556
- let sd = g.queryShapeDurations.get(shape);
1557
- if (!sd) {
1558
- sd = { totalMs: 0, count: 0, label: info.op + (info.table ? ` ${info.table}` : "") };
1559
- g.queryShapeDurations.set(shape, sd);
1560
- }
1561
- sd.totalMs += q.durationMs;
1562
- sd.count++;
1563
- }
1564
- const reqFetches = fetchesByReq.get(r.id) ?? [];
1565
- for (const f of reqFetches) {
1566
- g.totalFetchTimeMs += f.durationMs;
1567
- }
1568
- }
1539
+ for (const request of recent) {
1540
+ const endpointKey = getEndpointKey(request.method, request.path);
1541
+ const group = getOrCreate(endpointGroups, endpointKey, emptyEndpointGroup);
1542
+ group.total++;
1543
+ if (isErrorStatus(request.statusCode)) group.errors++;
1544
+ group.totalDuration += request.durationMs;
1545
+ group.totalSize += request.responseSize ?? 0;
1546
+ const reqQueries = queriesByReq.get(request.id) ?? [];
1547
+ group.queryCount += reqQueries.length;
1548
+ for (const query of reqQueries) {
1549
+ group.totalQueryTimeMs += query.durationMs;
1550
+ const shape = getQueryShape(query);
1551
+ const info = getQueryInfo(query);
1552
+ const shapeDuration = getOrCreate(group.queryShapeDurations, shape, () => ({
1553
+ totalMs: 0,
1554
+ count: 0,
1555
+ label: info.op + (info.table ? ` ${info.table}` : "")
1556
+ }));
1557
+ shapeDuration.totalMs += query.durationMs;
1558
+ shapeDuration.count++;
1559
+ }
1560
+ const reqFetches = fetchesByReq.get(request.id) ?? [];
1561
+ for (const fetch of reqFetches) {
1562
+ group.totalFetchTimeMs += fetch.durationMs;
1563
+ }
1564
+ }
1565
+ return endpointGroups;
1566
+ }
1567
+ function collectStrictModeDupeIds(ctx) {
1568
+ const ids = /* @__PURE__ */ new Set();
1569
+ for (const flow of ctx.flows) {
1570
+ for (const req of flow.requests) {
1571
+ if (req.isStrictModeDupe) ids.add(req.id);
1572
+ }
1573
+ }
1574
+ return ids;
1575
+ }
1576
+ function buildInsightContext(ctx) {
1577
+ const strictModeDupeIds = collectStrictModeDupeIds(ctx);
1578
+ const nonStatic = filterUserRequests(ctx.requests).filter((req) => !strictModeDupeIds.has(req.id));
1579
+ const filteredQueries = strictModeDupeIds.size > 0 ? ctx.queries.filter((q) => !q.parentRequestId || !strictModeDupeIds.has(q.parentRequestId)) : ctx.queries;
1580
+ const filteredFetches = strictModeDupeIds.size > 0 ? ctx.fetches.filter((f) => !f.parentRequestId || !strictModeDupeIds.has(f.parentRequestId)) : ctx.fetches;
1581
+ const queriesByReq = groupBy(filteredQueries, (query) => query.parentRequestId);
1582
+ const fetchesByReq = groupBy(filteredFetches, (fetch) => fetch.parentRequestId);
1583
+ const reqById = new Map(nonStatic.map((request) => [request.id, request]));
1584
+ const recent = keepRecentPerEndpoint(nonStatic);
1585
+ const endpointGroups = aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq);
1569
1586
  return {
1570
1587
  ...ctx,
1571
1588
  nonStatic,
@@ -1586,12 +1603,13 @@ var InsightRunner = class {
1586
1603
  this.rules.push(rule);
1587
1604
  }
1588
1605
  run(ctx) {
1589
- const prepared = prepareContext(ctx);
1606
+ const prepared = buildInsightContext(ctx);
1590
1607
  const insights = [];
1591
1608
  for (const rule of this.rules) {
1592
1609
  try {
1593
1610
  insights.push(...rule.check(prepared));
1594
- } catch {
1611
+ } catch (e) {
1612
+ brakitDebug(`insight rule ${rule.id} failed: ${getErrorMessage(e)}`);
1595
1613
  }
1596
1614
  }
1597
1615
  insights.sort(
@@ -1601,7 +1619,7 @@ var InsightRunner = class {
1601
1619
  }
1602
1620
  };
1603
1621
 
1604
- // src/analysis/insights/rules/n1.ts
1622
+ // src/analysis/insights/rules/query-rules.ts
1605
1623
  var n1Rule = {
1606
1624
  id: "n1",
1607
1625
  check(ctx) {
@@ -1612,15 +1630,15 @@ var n1Rule = {
1612
1630
  if (!req) continue;
1613
1631
  const endpoint = getEndpointKey(req.method, req.path);
1614
1632
  const shapeGroups = /* @__PURE__ */ new Map();
1615
- for (const q of reqQueries) {
1616
- const shape = getQueryShape(q);
1633
+ for (const query of reqQueries) {
1634
+ const shape = getQueryShape(query);
1617
1635
  let group = shapeGroups.get(shape);
1618
1636
  if (!group) {
1619
- group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: q };
1637
+ group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: query };
1620
1638
  shapeGroups.set(shape, group);
1621
1639
  }
1622
1640
  group.count++;
1623
- group.distinctSql.add(q.sql ?? shape);
1641
+ group.distinctSql.add(query.sql ?? shape);
1624
1642
  }
1625
1643
  for (const [, sg] of shapeGroups) {
1626
1644
  if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
@@ -1641,57 +1659,6 @@ var n1Rule = {
1641
1659
  return insights;
1642
1660
  }
1643
1661
  };
1644
-
1645
- // src/analysis/insights/rules/cross-endpoint.ts
1646
- var crossEndpointRule = {
1647
- id: "cross-endpoint",
1648
- check(ctx) {
1649
- const insights = [];
1650
- const queryMap = /* @__PURE__ */ new Map();
1651
- const allEndpoints = /* @__PURE__ */ new Set();
1652
- for (const [reqId, reqQueries] of ctx.queriesByReq) {
1653
- const req = ctx.reqById.get(reqId);
1654
- if (!req) continue;
1655
- const endpoint = getEndpointKey(req.method, req.path);
1656
- allEndpoints.add(endpoint);
1657
- const seenInReq = /* @__PURE__ */ new Set();
1658
- for (const q of reqQueries) {
1659
- const shape = getQueryShape(q);
1660
- let entry = queryMap.get(shape);
1661
- if (!entry) {
1662
- entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
1663
- queryMap.set(shape, entry);
1664
- }
1665
- entry.count++;
1666
- if (!seenInReq.has(shape)) {
1667
- seenInReq.add(shape);
1668
- entry.endpoints.add(endpoint);
1669
- }
1670
- }
1671
- }
1672
- if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
1673
- for (const [, cem] of queryMap) {
1674
- if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
1675
- if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
1676
- const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
1677
- if (p < CROSS_ENDPOINT_PCT) continue;
1678
- const info = getQueryInfo(cem.first);
1679
- const label = info.op + (info.table ? ` ${info.table}` : "");
1680
- insights.push({
1681
- severity: "warning",
1682
- type: "cross-endpoint",
1683
- title: "Repeated Query Across Endpoints",
1684
- desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
1685
- hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
1686
- nav: "queries"
1687
- });
1688
- }
1689
- }
1690
- return insights;
1691
- }
1692
- };
1693
-
1694
- // src/analysis/insights/rules/redundant-query.ts
1695
1662
  var redundantQueryRule = {
1696
1663
  id: "redundant-query",
1697
1664
  check(ctx) {
@@ -1702,27 +1669,27 @@ var redundantQueryRule = {
1702
1669
  if (!req) continue;
1703
1670
  const endpoint = getEndpointKey(req.method, req.path);
1704
1671
  const exact = /* @__PURE__ */ new Map();
1705
- for (const q of reqQueries) {
1706
- if (!q.sql) continue;
1707
- let entry = exact.get(q.sql);
1672
+ for (const query of reqQueries) {
1673
+ if (!query.sql) continue;
1674
+ let entry = exact.get(query.sql);
1708
1675
  if (!entry) {
1709
- entry = { count: 0, first: q };
1710
- exact.set(q.sql, entry);
1676
+ entry = { count: 0, first: query };
1677
+ exact.set(query.sql, entry);
1711
1678
  }
1712
1679
  entry.count++;
1713
1680
  }
1714
- for (const [, e] of exact) {
1715
- if (e.count < REDUNDANT_QUERY_MIN_COUNT) continue;
1716
- const info = getQueryInfo(e.first);
1681
+ for (const [, entry] of exact) {
1682
+ if (entry.count < REDUNDANT_QUERY_MIN_COUNT) continue;
1683
+ const info = getQueryInfo(entry.first);
1717
1684
  const label = info.op + (info.table ? ` ${info.table}` : "");
1718
- const dedupKey = `${endpoint}:${label}`;
1719
- if (seen.has(dedupKey)) continue;
1720
- seen.add(dedupKey);
1685
+ const deduplicationKey = `${endpoint}:${label}`;
1686
+ if (seen.has(deduplicationKey)) continue;
1687
+ seen.add(deduplicationKey);
1721
1688
  insights.push({
1722
1689
  severity: "warning",
1723
1690
  type: "redundant-query",
1724
1691
  title: "Redundant Query",
1725
- desc: `${label} runs ${e.count}x with identical params in ${endpoint}.`,
1692
+ desc: `${label} runs ${entry.count}x with identical params in ${endpoint}.`,
1726
1693
  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.",
1727
1694
  nav: "queries"
1728
1695
  });
@@ -1731,179 +1698,16 @@ var redundantQueryRule = {
1731
1698
  return insights;
1732
1699
  }
1733
1700
  };
1734
-
1735
- // src/analysis/insights/rules/error.ts
1736
- var errorRule = {
1737
- id: "error",
1738
- check(ctx) {
1739
- if (ctx.errors.length === 0) return [];
1740
- const insights = [];
1741
- const groups = /* @__PURE__ */ new Map();
1742
- for (const e of ctx.errors) {
1743
- const name = e.name || "Error";
1744
- groups.set(name, (groups.get(name) ?? 0) + 1);
1745
- }
1746
- for (const [name, cnt] of groups) {
1747
- insights.push({
1748
- severity: "critical",
1749
- type: "error",
1750
- title: "Unhandled Error",
1751
- desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
1752
- hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
1753
- nav: "errors"
1754
- });
1755
- }
1756
- return insights;
1757
- }
1758
- };
1759
-
1760
- // src/analysis/insights/rules/error-hotspot.ts
1761
- var errorHotspotRule = {
1762
- id: "error-hotspot",
1763
- check(ctx) {
1764
- const insights = [];
1765
- for (const [ep, g] of ctx.endpointGroups) {
1766
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1767
- const errorRate = Math.round(g.errors / g.total * 100);
1768
- if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
1769
- insights.push({
1770
- severity: "critical",
1771
- type: "error-hotspot",
1772
- title: "Error Hotspot",
1773
- desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
1774
- hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
1775
- nav: "requests"
1776
- });
1777
- }
1778
- }
1779
- return insights;
1780
- }
1781
- };
1782
-
1783
- // src/analysis/insights/rules/duplicate.ts
1784
- var duplicateRule = {
1785
- id: "duplicate",
1786
- check(ctx) {
1787
- const dupCounts = /* @__PURE__ */ new Map();
1788
- const flowCount = /* @__PURE__ */ new Map();
1789
- for (const flow of ctx.flows) {
1790
- if (!flow.requests) continue;
1791
- const seenInFlow = /* @__PURE__ */ new Set();
1792
- for (const fr of flow.requests) {
1793
- if (!fr.isDuplicate) continue;
1794
- const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
1795
- dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
1796
- if (!seenInFlow.has(dupKey)) {
1797
- seenInFlow.add(dupKey);
1798
- flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
1799
- }
1800
- }
1801
- }
1802
- const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
1803
- const insights = [];
1804
- for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
1805
- const d = dupEntries[i];
1806
- insights.push({
1807
- severity: "warning",
1808
- type: "duplicate",
1809
- title: "Duplicate API Call",
1810
- desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
1811
- 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.",
1812
- nav: "actions"
1813
- });
1814
- }
1815
- return insights;
1816
- }
1817
- };
1818
-
1819
- // src/utils/format.ts
1820
- function formatDuration(ms) {
1821
- if (ms < 1e3) return `${ms}ms`;
1822
- return `${(ms / 1e3).toFixed(1)}s`;
1823
- }
1824
- function formatSize(bytes) {
1825
- if (bytes < 1024) return `${bytes}B`;
1826
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1827
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
1828
- }
1829
- function pct(part, total) {
1830
- return total > 0 ? Math.round(part / total * 100) : 0;
1831
- }
1832
-
1833
- // src/analysis/insights/rules/slow.ts
1834
- var slowRule = {
1835
- id: "slow",
1836
- check(ctx) {
1837
- const insights = [];
1838
- for (const [ep, g] of ctx.endpointGroups) {
1839
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1840
- const avgMs = Math.round(g.totalDuration / g.total);
1841
- if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
1842
- const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
1843
- const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
1844
- const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
1845
- const parts = [];
1846
- if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
1847
- if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
1848
- if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
1849
- const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
1850
- let detail;
1851
- let slowestMs = 0;
1852
- for (const [, sd] of g.queryShapeDurations) {
1853
- const avgShapeMs = sd.totalMs / sd.count;
1854
- if (avgShapeMs > slowestMs) {
1855
- slowestMs = avgShapeMs;
1856
- detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
1857
- }
1858
- }
1859
- insights.push({
1860
- severity: "warning",
1861
- type: "slow",
1862
- title: "Slow Endpoint",
1863
- desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
1864
- 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.",
1865
- detail,
1866
- nav: "requests"
1867
- });
1868
- }
1869
- return insights;
1870
- }
1871
- };
1872
-
1873
- // src/analysis/insights/rules/query-heavy.ts
1874
- var queryHeavyRule = {
1875
- id: "query-heavy",
1876
- check(ctx) {
1877
- const insights = [];
1878
- for (const [ep, g] of ctx.endpointGroups) {
1879
- if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1880
- const avgQueries = Math.round(g.queryCount / g.total);
1881
- if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
1882
- insights.push({
1883
- severity: "warning",
1884
- type: "query-heavy",
1885
- title: "Query-Heavy Endpoint",
1886
- desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
1887
- hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
1888
- nav: "queries"
1889
- });
1890
- }
1891
- }
1892
- return insights;
1893
- }
1894
- };
1895
-
1896
- // src/analysis/insights/rules/select-star.ts
1897
1701
  var selectStarRule = {
1898
1702
  id: "select-star",
1899
1703
  check(ctx) {
1900
1704
  const seen = /* @__PURE__ */ new Map();
1901
1705
  for (const [, reqQueries] of ctx.queriesByReq) {
1902
- for (const q of reqQueries) {
1903
- if (!q.sql) continue;
1904
- const isSelectStar = SELECT_STAR_RE.test(q.sql.trim()) || SELECT_DOT_STAR_RE.test(q.sql);
1706
+ for (const query of reqQueries) {
1707
+ if (!query.sql) continue;
1708
+ const isSelectStar = SELECT_STAR_RE.test(query.sql.trim()) || SELECT_DOT_STAR_RE.test(query.sql);
1905
1709
  if (!isSelectStar) continue;
1906
- const info = getQueryInfo(q);
1710
+ const info = getQueryInfo(query);
1907
1711
  const key = info.table || "unknown";
1908
1712
  seen.set(key, (seen.get(key) ?? 0) + 1);
1909
1713
  }
@@ -1923,16 +1727,14 @@ var selectStarRule = {
1923
1727
  return insights;
1924
1728
  }
1925
1729
  };
1926
-
1927
- // src/analysis/insights/rules/high-rows.ts
1928
1730
  var highRowsRule = {
1929
1731
  id: "high-rows",
1930
1732
  check(ctx) {
1931
1733
  const seen = /* @__PURE__ */ new Map();
1932
1734
  for (const [, reqQueries] of ctx.queriesByReq) {
1933
- for (const q of reqQueries) {
1934
- if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
1935
- const info = getQueryInfo(q);
1735
+ for (const query of reqQueries) {
1736
+ if (!query.rowCount || query.rowCount <= HIGH_ROW_COUNT) continue;
1737
+ const info = getQueryInfo(query);
1936
1738
  const key = `${info.op} ${info.table || "unknown"}`;
1937
1739
  let entry = seen.get(key);
1938
1740
  if (!entry) {
@@ -1940,7 +1742,7 @@ var highRowsRule = {
1940
1742
  seen.set(key, entry);
1941
1743
  }
1942
1744
  entry.count++;
1943
- if (q.rowCount > entry.max) entry.max = q.rowCount;
1745
+ if (query.rowCount > entry.max) entry.max = query.rowCount;
1944
1746
  }
1945
1747
  }
1946
1748
  const insights = [];
@@ -1958,21 +1760,57 @@ var highRowsRule = {
1958
1760
  return insights;
1959
1761
  }
1960
1762
  };
1763
+ var queryHeavyRule = {
1764
+ id: "query-heavy",
1765
+ check(ctx) {
1766
+ const insights = [];
1767
+ for (const [endpointKey, group] of ctx.endpointGroups) {
1768
+ if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1769
+ const avgQueries = Math.round(group.queryCount / group.total);
1770
+ if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
1771
+ insights.push({
1772
+ severity: "warning",
1773
+ type: "query-heavy",
1774
+ title: "Query-Heavy Endpoint",
1775
+ desc: `${endpointKey} \u2014 avg ${avgQueries} queries/request`,
1776
+ hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
1777
+ nav: "queries"
1778
+ });
1779
+ }
1780
+ }
1781
+ return insights;
1782
+ }
1783
+ };
1961
1784
 
1962
- // src/analysis/insights/rules/response-overfetch.ts
1785
+ // src/utils/format.ts
1786
+ function formatDuration(ms) {
1787
+ if (ms < 1e3) return `${ms}ms`;
1788
+ return `${(ms / 1e3).toFixed(1)}s`;
1789
+ }
1790
+ function formatSize(bytes) {
1791
+ if (bytes < 1024) return `${bytes}B`;
1792
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1793
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
1794
+ }
1795
+ function pct(part, total) {
1796
+ return total > 0 ? Math.round(part / total * 100) : 0;
1797
+ }
1798
+
1799
+ // src/analysis/insights/rules/response-rules.ts
1963
1800
  var responseOverfetchRule = {
1964
1801
  id: "response-overfetch",
1965
1802
  check(ctx) {
1966
1803
  const insights = [];
1967
1804
  const seen = /* @__PURE__ */ new Set();
1968
- for (const r of ctx.nonStatic) {
1969
- if (isErrorStatus(r.statusCode) || !r.responseBody) continue;
1970
- const ep = getEndpointKey(r.method, r.path);
1971
- if (seen.has(ep)) continue;
1805
+ for (const request of ctx.nonStatic) {
1806
+ if (isErrorStatus(request.statusCode) || !request.responseBody) continue;
1807
+ const endpointKey = getEndpointKey(request.method, request.path);
1808
+ if (seen.has(endpointKey)) continue;
1972
1809
  let parsed;
1973
1810
  try {
1974
- parsed = JSON.parse(r.responseBody);
1975
- } catch {
1811
+ parsed = JSON.parse(request.responseBody);
1812
+ } catch (e) {
1813
+ brakitDebug(`json parse: ${getErrorMessage(e)}`);
1976
1814
  continue;
1977
1815
  }
1978
1816
  const target = unwrapResponse(parsed);
@@ -1995,12 +1833,12 @@ var responseOverfetchRule = {
1995
1833
  reasons.push(`${fields.length} fields returned`);
1996
1834
  }
1997
1835
  if (reasons.length > 0) {
1998
- seen.add(ep);
1836
+ seen.add(endpointKey);
1999
1837
  insights.push({
2000
1838
  severity: "info",
2001
1839
  type: "response-overfetch",
2002
1840
  title: "Response Overfetch",
2003
- desc: `${ep} \u2014 ${reasons.join(", ")}`,
1841
+ desc: `${endpointKey} \u2014 ${reasons.join(", ")}`,
2004
1842
  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.",
2005
1843
  nav: "requests"
2006
1844
  });
@@ -2009,21 +1847,19 @@ var responseOverfetchRule = {
2009
1847
  return insights;
2010
1848
  }
2011
1849
  };
2012
-
2013
- // src/analysis/insights/rules/large-response.ts
2014
1850
  var largeResponseRule = {
2015
1851
  id: "large-response",
2016
1852
  check(ctx) {
2017
1853
  const insights = [];
2018
- for (const [ep, g] of ctx.endpointGroups) {
2019
- if (g.total < OVERFETCH_MIN_REQUESTS) continue;
2020
- const avgSize = Math.round(g.totalSize / g.total);
1854
+ for (const [endpointKey, group] of ctx.endpointGroups) {
1855
+ if (group.total < OVERFETCH_MIN_REQUESTS) continue;
1856
+ const avgSize = Math.round(group.totalSize / group.total);
2021
1857
  if (avgSize > LARGE_RESPONSE_BYTES) {
2022
1858
  insights.push({
2023
1859
  severity: "info",
2024
1860
  type: "large-response",
2025
1861
  title: "Large Response",
2026
- desc: `${ep} \u2014 avg ${formatSize(avgSize)} response`,
1862
+ desc: `${endpointKey} \u2014 avg ${formatSize(avgSize)} response`,
2027
1863
  hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
2028
1864
  nav: "requests"
2029
1865
  });
@@ -2033,7 +1869,51 @@ var largeResponseRule = {
2033
1869
  }
2034
1870
  };
2035
1871
 
2036
- // src/analysis/insights/rules/regression.ts
1872
+ // src/analysis/insights/rules/reliability-rules.ts
1873
+ var errorRule = {
1874
+ id: "error",
1875
+ check(ctx) {
1876
+ if (ctx.errors.length === 0) return [];
1877
+ const insights = [];
1878
+ const groups = /* @__PURE__ */ new Map();
1879
+ for (const error of ctx.errors) {
1880
+ const name = error.name || "Error";
1881
+ groups.set(name, (groups.get(name) ?? 0) + 1);
1882
+ }
1883
+ for (const [name, cnt] of groups) {
1884
+ insights.push({
1885
+ severity: "critical",
1886
+ type: "error",
1887
+ title: "Unhandled Error",
1888
+ desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
1889
+ hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
1890
+ nav: "errors"
1891
+ });
1892
+ }
1893
+ return insights;
1894
+ }
1895
+ };
1896
+ var errorHotspotRule = {
1897
+ id: "error-hotspot",
1898
+ check(ctx) {
1899
+ const insights = [];
1900
+ for (const [endpointKey, group] of ctx.endpointGroups) {
1901
+ if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1902
+ const errorRate = Math.round(group.errors / group.total * 100);
1903
+ if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
1904
+ insights.push({
1905
+ severity: "critical",
1906
+ type: "error-hotspot",
1907
+ title: "Error Hotspot",
1908
+ desc: `${endpointKey} \u2014 ${errorRate}% error rate (${group.errors}/${group.total} requests)`,
1909
+ hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
1910
+ nav: "requests"
1911
+ });
1912
+ }
1913
+ }
1914
+ return insights;
1915
+ }
1916
+ };
2037
1917
  var regressionRule = {
2038
1918
  id: "regression",
2039
1919
  check(ctx) {
@@ -2070,6 +1950,127 @@ var regressionRule = {
2070
1950
  return insights;
2071
1951
  }
2072
1952
  };
1953
+ var slowRule = {
1954
+ id: "slow",
1955
+ check(ctx) {
1956
+ const insights = [];
1957
+ for (const [endpointKey, group] of ctx.endpointGroups) {
1958
+ if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
1959
+ const avgMs = Math.round(group.totalDuration / group.total);
1960
+ if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
1961
+ const avgQueryMs = Math.round(group.totalQueryTimeMs / group.total);
1962
+ const avgFetchMs = Math.round(group.totalFetchTimeMs / group.total);
1963
+ const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
1964
+ const parts = [];
1965
+ if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
1966
+ if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
1967
+ if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
1968
+ const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
1969
+ let detail;
1970
+ let slowestMs = 0;
1971
+ for (const [, shapeDuration] of group.queryShapeDurations) {
1972
+ const avgShapeMs = shapeDuration.totalMs / shapeDuration.count;
1973
+ if (avgShapeMs > slowestMs) {
1974
+ slowestMs = avgShapeMs;
1975
+ detail = `Slowest query: ${shapeDuration.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${shapeDuration.count}x)`;
1976
+ }
1977
+ }
1978
+ insights.push({
1979
+ severity: "warning",
1980
+ type: "slow",
1981
+ title: "Slow Endpoint",
1982
+ desc: `${endpointKey} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
1983
+ 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.",
1984
+ detail,
1985
+ nav: "requests"
1986
+ });
1987
+ }
1988
+ return insights;
1989
+ }
1990
+ };
1991
+
1992
+ // src/analysis/insights/rules/pattern-rules.ts
1993
+ var duplicateRule = {
1994
+ id: "duplicate",
1995
+ check(ctx) {
1996
+ const dupCounts = /* @__PURE__ */ new Map();
1997
+ const flowCount = /* @__PURE__ */ new Map();
1998
+ for (const flow of ctx.flows) {
1999
+ if (!flow.requests) continue;
2000
+ const seenInFlow = /* @__PURE__ */ new Set();
2001
+ for (const request of flow.requests) {
2002
+ if (!request.isDuplicate) continue;
2003
+ const deduplicationKey = `${request.method} ${request.label ?? request.path ?? request.url}`;
2004
+ dupCounts.set(deduplicationKey, (dupCounts.get(deduplicationKey) ?? 0) + 1);
2005
+ if (!seenInFlow.has(deduplicationKey)) {
2006
+ seenInFlow.add(deduplicationKey);
2007
+ flowCount.set(deduplicationKey, (flowCount.get(deduplicationKey) ?? 0) + 1);
2008
+ }
2009
+ }
2010
+ }
2011
+ const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
2012
+ const insights = [];
2013
+ for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
2014
+ const duplicate = dupEntries[i];
2015
+ insights.push({
2016
+ severity: "warning",
2017
+ type: "duplicate",
2018
+ title: "Duplicate API Call",
2019
+ desc: `${duplicate.key} loaded ${duplicate.count}x as duplicate across ${duplicate.flows} action${duplicate.flows !== 1 ? "s" : ""}`,
2020
+ 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.",
2021
+ nav: "actions"
2022
+ });
2023
+ }
2024
+ return insights;
2025
+ }
2026
+ };
2027
+ var crossEndpointRule = {
2028
+ id: "cross-endpoint",
2029
+ check(ctx) {
2030
+ const insights = [];
2031
+ const queryMap = /* @__PURE__ */ new Map();
2032
+ const allEndpoints = /* @__PURE__ */ new Set();
2033
+ for (const [reqId, reqQueries] of ctx.queriesByReq) {
2034
+ const req = ctx.reqById.get(reqId);
2035
+ if (!req) continue;
2036
+ const endpoint = getEndpointKey(req.method, req.path);
2037
+ allEndpoints.add(endpoint);
2038
+ const seenInReq = /* @__PURE__ */ new Set();
2039
+ for (const query of reqQueries) {
2040
+ const shape = getQueryShape(query);
2041
+ let entry = queryMap.get(shape);
2042
+ if (!entry) {
2043
+ entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: query };
2044
+ queryMap.set(shape, entry);
2045
+ }
2046
+ entry.count++;
2047
+ if (!seenInReq.has(shape)) {
2048
+ seenInReq.add(shape);
2049
+ entry.endpoints.add(endpoint);
2050
+ }
2051
+ }
2052
+ }
2053
+ if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
2054
+ for (const [, queryMetric] of queryMap) {
2055
+ if (queryMetric.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
2056
+ if (queryMetric.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
2057
+ const coveragePct = Math.round(queryMetric.endpoints.size / allEndpoints.size * 100);
2058
+ if (coveragePct < CROSS_ENDPOINT_PCT) continue;
2059
+ const info = getQueryInfo(queryMetric.first);
2060
+ const label = info.op + (info.table ? ` ${info.table}` : "");
2061
+ insights.push({
2062
+ severity: "warning",
2063
+ type: "cross-endpoint",
2064
+ title: "Repeated Query Across Endpoints",
2065
+ desc: `${label} runs on ${queryMetric.endpoints.size} of ${allEndpoints.size} endpoints (${coveragePct}%).`,
2066
+ hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
2067
+ nav: "queries"
2068
+ });
2069
+ }
2070
+ }
2071
+ return insights;
2072
+ }
2073
+ };
2073
2074
 
2074
2075
  // src/analysis/insights/rules/security.ts
2075
2076
  var securityRule = {
@@ -2144,8 +2145,8 @@ function securityFindingToIssue(finding) {
2144
2145
 
2145
2146
  // src/analysis/engine.ts
2146
2147
  var AnalysisEngine = class {
2147
- constructor(registry, debounceMs = ANALYSIS_DEBOUNCE_MS) {
2148
- this.registry = registry;
2148
+ constructor(services, debounceMs = ANALYSIS_DEBOUNCE_MS) {
2149
+ this.services = services;
2149
2150
  this.debounceMs = debounceMs;
2150
2151
  this.cachedInsights = [];
2151
2152
  this.cachedFindings = [];
@@ -2154,7 +2155,7 @@ var AnalysisEngine = class {
2154
2155
  this.scanner = createDefaultScanner();
2155
2156
  }
2156
2157
  start() {
2157
- const bus = this.registry.get("event-bus");
2158
+ const bus = this.services.bus;
2158
2159
  this.subs.add(bus.on("request:completed", () => this.scheduleRecompute()));
2159
2160
  this.subs.add(bus.on("telemetry:query", () => this.scheduleRecompute()));
2160
2161
  this.subs.add(bus.on("telemetry:error", () => this.scheduleRecompute()));
@@ -2181,12 +2182,12 @@ var AnalysisEngine = class {
2181
2182
  }, this.debounceMs);
2182
2183
  }
2183
2184
  recompute() {
2184
- const allRequests = this.registry.get("request-store").getAll();
2185
- const queries = this.registry.get("query-store").getAll();
2186
- const errors = this.registry.get("error-store").getAll();
2187
- const logs = this.registry.get("log-store").getAll();
2188
- const fetches = this.registry.get("fetch-store").getAll();
2189
- const requests = windowByEndpoint(allRequests);
2185
+ const allRequests = this.services.requestStore.getAll();
2186
+ const queries = this.services.queryStore.getAll();
2187
+ const errors = this.services.errorStore.getAll();
2188
+ const logs = this.services.logStore.getAll();
2189
+ const fetches = this.services.fetchStore.getAll();
2190
+ const requests = keepRecentPerEndpoint(allRequests);
2190
2191
  const flows = groupRequestsIntoFlows(requests);
2191
2192
  this.cachedFindings = this.scanner.scan({ requests, logs });
2192
2193
  this.cachedInsights = computeInsights({
@@ -2195,38 +2196,34 @@ var AnalysisEngine = class {
2195
2196
  errors,
2196
2197
  flows,
2197
2198
  fetches,
2198
- previousMetrics: this.registry.get("metrics-store").getAll(),
2199
+ previousMetrics: this.services.metricsStore.getAll(),
2199
2200
  securityFindings: this.cachedFindings
2200
2201
  });
2201
- if (this.registry.has("issue-store")) {
2202
- const issueStore = this.registry.get("issue-store");
2203
- for (const finding of this.cachedFindings) {
2204
- issueStore.upsert(securityFindingToIssue(finding), "passive");
2205
- }
2206
- for (const insight of this.cachedInsights) {
2207
- issueStore.upsert(insightToIssue(insight), "passive");
2208
- }
2209
- const currentIssueIds = /* @__PURE__ */ new Set();
2210
- for (const finding of this.cachedFindings) {
2211
- currentIssueIds.add(computeIssueId(securityFindingToIssue(finding)));
2212
- }
2213
- for (const insight of this.cachedInsights) {
2214
- currentIssueIds.add(computeIssueId(insightToIssue(insight)));
2215
- }
2216
- const activeEndpoints = extractActiveEndpoints(allRequests);
2217
- issueStore.reconcile(currentIssueIds, activeEndpoints);
2218
- const update = {
2219
- insights: this.cachedInsights,
2220
- findings: this.cachedFindings,
2221
- issues: issueStore.getAll()
2222
- };
2223
- this.registry.get("event-bus").emit("analysis:updated", update);
2224
- }
2202
+ const issueStore = this.services.issueStore;
2203
+ const currentIssueIds = /* @__PURE__ */ new Set();
2204
+ for (const finding of this.cachedFindings) {
2205
+ const issue = securityFindingToIssue(finding);
2206
+ issueStore.upsert(issue, "passive");
2207
+ currentIssueIds.add(computeIssueId(issue));
2208
+ }
2209
+ for (const insight of this.cachedInsights) {
2210
+ const issue = insightToIssue(insight);
2211
+ issueStore.upsert(issue, "passive");
2212
+ currentIssueIds.add(computeIssueId(issue));
2213
+ }
2214
+ const activeEndpoints = extractActiveEndpoints(allRequests);
2215
+ issueStore.reconcile(currentIssueIds, activeEndpoints);
2216
+ const update = {
2217
+ insights: this.cachedInsights,
2218
+ findings: this.cachedFindings,
2219
+ issues: issueStore.getAll()
2220
+ };
2221
+ this.services.bus.emit("analysis:updated", update);
2225
2222
  }
2226
2223
  };
2227
2224
 
2228
2225
  // src/index.ts
2229
- var VERSION = "0.8.7";
2226
+ var VERSION = "9.0.0";
2230
2227
  export {
2231
2228
  AdapterRegistry,
2232
2229
  AnalysisEngine,