brakit 0.8.5 → 0.8.7

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
@@ -1,12 +1,54 @@
1
- // src/store/finding-store.ts
1
+ // src/store/issue-store.ts
2
2
  import { readFile as readFile2 } from "fs/promises";
3
- import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
3
+ import { readFileSync as readFileSync2, existsSync as existsSync3, unlinkSync } from "fs";
4
4
  import { resolve as resolve2 } from "path";
5
5
 
6
6
  // src/utils/fs.ts
7
7
  import { access, readFile, writeFile } from "fs/promises";
8
8
  import { existsSync, readFileSync, writeFileSync } from "fs";
9
- import { resolve } from "path";
9
+ import { createHash } from "crypto";
10
+ import { homedir } from "os";
11
+ import { resolve, join } from "path";
12
+
13
+ // src/constants/limits.ts
14
+ var ANALYSIS_DEBOUNCE_MS = 300;
15
+ var ISSUE_ID_HASH_LENGTH = 16;
16
+ var ISSUES_DATA_VERSION = 2;
17
+ var SECRET_SCAN_ARRAY_LIMIT = 5;
18
+ var PII_SCAN_ARRAY_LIMIT = 10;
19
+ var MIN_SECRET_VALUE_LENGTH = 8;
20
+ var FULL_RECORD_MIN_FIELDS = 8;
21
+ var LIST_PII_MIN_ITEMS = 2;
22
+ var MAX_OBJECT_SCAN_DEPTH = 5;
23
+ var ISSUE_PRUNE_TTL_MS = 10 * 60 * 1e3;
24
+
25
+ // src/utils/log.ts
26
+ var PREFIX = "[brakit]";
27
+ function brakitWarn(message) {
28
+ process.stderr.write(`${PREFIX} ${message}
29
+ `);
30
+ }
31
+ function brakitDebug(message) {
32
+ if (process.env.DEBUG_BRAKIT) {
33
+ process.stderr.write(`${PREFIX}:debug ${message}
34
+ `);
35
+ }
36
+ }
37
+
38
+ // src/utils/type-guards.ts
39
+ function getErrorMessage(err) {
40
+ if (err instanceof Error) return err.message;
41
+ if (typeof err === "string") return err;
42
+ return String(err);
43
+ }
44
+ function validateIssuesData(parsed) {
45
+ if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.version === ISSUES_DATA_VERSION && Array.isArray(parsed.issues)) {
46
+ return parsed;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ // src/utils/fs.ts
10
52
  async function fileExists(path) {
11
53
  try {
12
54
  await access(path);
@@ -25,7 +67,8 @@ function ensureGitignore(dir, entry) {
25
67
  } else {
26
68
  writeFileSync(gitignorePath, entry + "\n");
27
69
  }
28
- } catch {
70
+ } catch (err) {
71
+ brakitDebug(`ensureGitignore failed: ${getErrorMessage(err)}`);
29
72
  }
30
73
  }
31
74
  async function ensureGitignoreAsync(dir, entry) {
@@ -38,46 +81,14 @@ async function ensureGitignoreAsync(dir, entry) {
38
81
  } else {
39
82
  await writeFile(gitignorePath, entry + "\n");
40
83
  }
41
- } catch {
84
+ } catch (err) {
85
+ brakitDebug(`ensureGitignoreAsync failed: ${getErrorMessage(err)}`);
42
86
  }
43
87
  }
44
88
 
45
- // src/constants/routes.ts
46
- var DASHBOARD_PREFIX = "/__brakit";
47
- var DASHBOARD_API_REQUESTS = `${DASHBOARD_PREFIX}/api/requests`;
48
- var DASHBOARD_API_EVENTS = `${DASHBOARD_PREFIX}/api/events`;
49
- var DASHBOARD_API_FLOWS = `${DASHBOARD_PREFIX}/api/flows`;
50
- var DASHBOARD_API_CLEAR = `${DASHBOARD_PREFIX}/api/clear`;
51
- var DASHBOARD_API_LOGS = `${DASHBOARD_PREFIX}/api/logs`;
52
- var DASHBOARD_API_FETCHES = `${DASHBOARD_PREFIX}/api/fetches`;
53
- var DASHBOARD_API_ERRORS = `${DASHBOARD_PREFIX}/api/errors`;
54
- var DASHBOARD_API_QUERIES = `${DASHBOARD_PREFIX}/api/queries`;
55
- var DASHBOARD_API_INGEST = `${DASHBOARD_PREFIX}/api/ingest`;
56
- var DASHBOARD_API_METRICS = `${DASHBOARD_PREFIX}/api/metrics`;
57
- var DASHBOARD_API_ACTIVITY = `${DASHBOARD_PREFIX}/api/activity`;
58
- var DASHBOARD_API_METRICS_LIVE = `${DASHBOARD_PREFIX}/api/metrics/live`;
59
- var DASHBOARD_API_INSIGHTS = `${DASHBOARD_PREFIX}/api/insights`;
60
- var DASHBOARD_API_SECURITY = `${DASHBOARD_PREFIX}/api/security`;
61
- var DASHBOARD_API_TAB = `${DASHBOARD_PREFIX}/api/tab`;
62
- var DASHBOARD_API_FINDINGS = `${DASHBOARD_PREFIX}/api/findings`;
63
- var DASHBOARD_API_FINDINGS_REPORT = `${DASHBOARD_PREFIX}/api/findings/report`;
64
- var VALID_TABS_TUPLE = [
65
- "overview",
66
- "actions",
67
- "requests",
68
- "fetches",
69
- "queries",
70
- "errors",
71
- "logs",
72
- "performance",
73
- "security"
74
- ];
75
- var VALID_TABS = new Set(VALID_TABS_TUPLE);
76
-
77
- // src/constants/limits.ts
78
- var ANALYSIS_DEBOUNCE_MS = 300;
79
- var FINDING_ID_HASH_LENGTH = 16;
80
- var FINDINGS_DATA_VERSION = 1;
89
+ // src/constants/metrics.ts
90
+ var ISSUES_FILE = "issues.json";
91
+ var ISSUES_FLUSH_INTERVAL_MS = 1e4;
81
92
 
82
93
  // src/constants/thresholds.ts
83
94
  var FLOW_GAP_MS = 5e3;
@@ -106,17 +117,9 @@ var QUERY_COUNT_REGRESSION_RATIO = 1.5;
106
117
  var OVERFETCH_MANY_FIELDS = 12;
107
118
  var OVERFETCH_UNWRAP_MIN_SIZE = 3;
108
119
  var MAX_DUPLICATE_INSIGHTS = 3;
109
- var INSIGHT_WINDOW_PER_ENDPOINT = 2;
110
- var RESOLVE_AFTER_ABSENCES = 3;
111
- var RESOLVED_INSIGHT_TTL_MS = 18e5;
112
-
113
- // src/constants/metrics.ts
114
- var METRICS_DIR = ".brakit";
115
- var FINDINGS_FILE = ".brakit/findings.json";
116
- var FINDINGS_FLUSH_INTERVAL_MS = 1e4;
117
-
118
- // src/constants/network.ts
119
- var RECOVERY_WINDOW_MS = 5 * 60 * 1e3;
120
+ var INSIGHT_WINDOW_PER_ENDPOINT = 20;
121
+ var CLEAN_HITS_FOR_RESOLUTION = 5;
122
+ var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
120
123
 
121
124
  // src/utils/atomic-writer.ts
122
125
  import {
@@ -126,36 +129,13 @@ import {
126
129
  renameSync
127
130
  } from "fs";
128
131
  import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
129
-
130
- // src/utils/log.ts
131
- var PREFIX = "[brakit]";
132
- function brakitWarn(message) {
133
- process.stderr.write(`${PREFIX} ${message}
134
- `);
135
- }
136
- function brakitDebug(message) {
137
- if (process.env.DEBUG_BRAKIT) {
138
- process.stderr.write(`${PREFIX}:debug ${message}
139
- `);
140
- }
141
- }
142
-
143
- // src/utils/type-guards.ts
144
- function getErrorMessage(err) {
145
- if (err instanceof Error) return err.message;
146
- if (typeof err === "string") return err;
147
- return String(err);
148
- }
149
-
150
- // src/utils/atomic-writer.ts
151
132
  var AtomicWriter = class {
152
133
  constructor(opts) {
153
134
  this.opts = opts;
135
+ this.writing = false;
136
+ this.pendingContent = null;
154
137
  this.tmpPath = opts.filePath + ".tmp";
155
138
  }
156
- tmpPath;
157
- writing = false;
158
- pendingContent = null;
159
139
  writeSync(content) {
160
140
  try {
161
141
  this.ensureDir();
@@ -205,41 +185,33 @@ var AtomicWriter = class {
205
185
  }
206
186
  };
207
187
 
208
- // src/store/finding-id.ts
209
- import { createHash } from "crypto";
210
- function computeFindingId(finding) {
211
- const key = `${finding.rule}:${finding.endpoint}:${finding.desc}`;
212
- return createHash("sha256").update(key).digest("hex").slice(0, FINDING_ID_HASH_LENGTH);
213
- }
214
- function computeInsightId(type, endpoint, desc) {
215
- const key = `${type}:${endpoint}:${desc}`;
216
- return createHash("sha256").update(key).digest("hex").slice(0, FINDING_ID_HASH_LENGTH);
188
+ // src/utils/issue-id.ts
189
+ import { createHash as createHash2 } from "crypto";
190
+ function computeIssueId(issue) {
191
+ const stableDesc = issue.desc.replace(/\d[\d,.]*\s*\w*/g, "#");
192
+ const key = `${issue.rule}:${issue.endpoint ?? "global"}:${stableDesc}`;
193
+ return createHash2("sha256").update(key).digest("hex").slice(0, ISSUE_ID_HASH_LENGTH);
217
194
  }
218
195
 
219
- // src/store/finding-store.ts
220
- var FindingStore = class {
221
- constructor(rootDir) {
222
- this.rootDir = rootDir;
223
- const metricsDir = resolve2(rootDir, METRICS_DIR);
224
- this.findingsPath = resolve2(rootDir, FINDINGS_FILE);
196
+ // src/store/issue-store.ts
197
+ var IssueStore = class {
198
+ constructor(dataDir) {
199
+ this.dataDir = dataDir;
200
+ this.issues = /* @__PURE__ */ new Map();
201
+ this.flushTimer = null;
202
+ this.dirty = false;
203
+ this.issuesPath = resolve2(dataDir, ISSUES_FILE);
225
204
  this.writer = new AtomicWriter({
226
- dir: metricsDir,
227
- filePath: this.findingsPath,
228
- gitignoreEntry: METRICS_DIR,
229
- label: "findings"
205
+ dir: dataDir,
206
+ filePath: this.issuesPath,
207
+ label: "issues"
230
208
  });
231
209
  }
232
- findings = /* @__PURE__ */ new Map();
233
- flushTimer = null;
234
- dirty = false;
235
- writer;
236
- findingsPath;
237
210
  start() {
238
- this.loadAsync().catch(() => {
239
- });
211
+ this.loadAsync().catch((err) => brakitDebug(`IssueStore: async load failed: ${err}`));
240
212
  this.flushTimer = setInterval(
241
213
  () => this.flush(),
242
- FINDINGS_FLUSH_INTERVAL_MS
214
+ ISSUES_FLUSH_INTERVAL_MS
243
215
  );
244
216
  this.flushTimer.unref();
245
217
  }
@@ -250,119 +222,148 @@ var FindingStore = class {
250
222
  }
251
223
  this.flushSync();
252
224
  }
253
- upsert(finding, source) {
254
- const id = computeFindingId(finding);
255
- const existing = this.findings.get(id);
225
+ upsert(issue, source) {
226
+ const id = computeIssueId(issue);
227
+ const existing = this.issues.get(id);
256
228
  const now = Date.now();
257
229
  if (existing) {
258
230
  existing.lastSeenAt = now;
259
231
  existing.occurrences++;
260
- existing.finding = finding;
261
- if (existing.state === "resolved") {
262
- existing.state = "open";
232
+ existing.issue = issue;
233
+ existing.cleanHitsSinceLastSeen = 0;
234
+ if (existing.state === "resolved" || existing.state === "stale") {
235
+ existing.state = "regressed";
263
236
  existing.resolvedAt = null;
264
237
  }
265
238
  this.dirty = true;
266
239
  return existing;
267
240
  }
268
241
  const stateful = {
269
- findingId: id,
242
+ issueId: id,
270
243
  state: "open",
271
244
  source,
272
- finding,
245
+ category: issue.category,
246
+ issue,
273
247
  firstSeenAt: now,
274
248
  lastSeenAt: now,
275
249
  resolvedAt: null,
276
250
  occurrences: 1,
251
+ cleanHitsSinceLastSeen: 0,
277
252
  aiStatus: null,
278
253
  aiNotes: null
279
254
  };
280
- this.findings.set(id, stateful);
255
+ this.issues.set(id, stateful);
281
256
  this.dirty = true;
282
257
  return stateful;
283
258
  }
284
- transition(findingId, state) {
285
- const finding = this.findings.get(findingId);
286
- if (!finding) return false;
287
- finding.state = state;
259
+ /**
260
+ * Reconcile issues against the current analysis results using evidence-based resolution.
261
+ *
262
+ * @param currentIssueIds - IDs of issues detected in the current analysis cycle
263
+ * @param activeEndpoints - Endpoints that had requests in the current cycle
264
+ */
265
+ reconcile(currentIssueIds, activeEndpoints) {
266
+ const now = Date.now();
267
+ for (const [, stateful] of this.issues) {
268
+ const isActive = stateful.state === "open" || stateful.state === "fixing" || stateful.state === "regressed";
269
+ if (!isActive) continue;
270
+ if (currentIssueIds.has(stateful.issueId)) continue;
271
+ const endpoint = stateful.issue.endpoint;
272
+ if (endpoint && activeEndpoints.has(endpoint)) {
273
+ stateful.cleanHitsSinceLastSeen++;
274
+ if (stateful.cleanHitsSinceLastSeen >= CLEAN_HITS_FOR_RESOLUTION) {
275
+ stateful.state = "resolved";
276
+ stateful.resolvedAt = now;
277
+ }
278
+ this.dirty = true;
279
+ } else if (now - stateful.lastSeenAt > STALE_ISSUE_TTL_MS) {
280
+ stateful.state = "stale";
281
+ this.dirty = true;
282
+ }
283
+ }
284
+ for (const [id, stateful] of this.issues) {
285
+ if (stateful.state === "resolved" && stateful.resolvedAt && now - stateful.resolvedAt > ISSUE_PRUNE_TTL_MS) {
286
+ this.issues.delete(id);
287
+ this.dirty = true;
288
+ } else if (stateful.state === "stale" && now - stateful.lastSeenAt > STALE_ISSUE_TTL_MS + ISSUE_PRUNE_TTL_MS) {
289
+ this.issues.delete(id);
290
+ this.dirty = true;
291
+ }
292
+ }
293
+ }
294
+ transition(issueId, state) {
295
+ const issue = this.issues.get(issueId);
296
+ if (!issue) return false;
297
+ issue.state = state;
288
298
  if (state === "resolved") {
289
- finding.resolvedAt = Date.now();
299
+ issue.resolvedAt = Date.now();
290
300
  }
291
301
  this.dirty = true;
292
302
  return true;
293
303
  }
294
- reportFix(findingId, status, notes) {
295
- const finding = this.findings.get(findingId);
296
- if (!finding) return false;
297
- finding.aiStatus = status;
298
- finding.aiNotes = notes;
304
+ reportFix(issueId, status, notes) {
305
+ const issue = this.issues.get(issueId);
306
+ if (!issue) return false;
307
+ issue.aiStatus = status;
308
+ issue.aiNotes = notes;
299
309
  if (status === "fixed") {
300
- finding.state = "fixing";
310
+ issue.state = "fixing";
301
311
  }
302
312
  this.dirty = true;
303
313
  return true;
304
314
  }
305
- /**
306
- * Reconcile passive findings against the current analysis results.
307
- *
308
- * Passive findings are detected by continuous scanning (not user-triggered).
309
- * When a previously-seen finding is absent from the current results, it means
310
- * the issue has been fixed — transition it to "resolved" automatically.
311
- * Active findings (from MCP verify-fix) are not auto-resolved because they
312
- * require explicit verification.
313
- */
314
- reconcilePassive(currentFindings) {
315
- const currentIds = new Set(currentFindings.map(computeFindingId));
316
- for (const [id, stateful] of this.findings) {
317
- if (stateful.source === "passive" && (stateful.state === "open" || stateful.state === "fixing") && !currentIds.has(id)) {
318
- stateful.state = "resolved";
319
- stateful.resolvedAt = Date.now();
320
- this.dirty = true;
321
- }
322
- }
323
- }
324
315
  getAll() {
325
- return [...this.findings.values()];
316
+ return [...this.issues.values()];
326
317
  }
327
318
  getByState(state) {
328
- return [...this.findings.values()].filter((f) => f.state === state);
319
+ return [...this.issues.values()].filter((i) => i.state === state);
320
+ }
321
+ getByCategory(category) {
322
+ return [...this.issues.values()].filter((i) => i.category === category);
329
323
  }
330
- get(findingId) {
331
- return this.findings.get(findingId);
324
+ get(issueId) {
325
+ return this.issues.get(issueId);
332
326
  }
333
327
  clear() {
334
- this.findings.clear();
335
- this.dirty = true;
328
+ this.issues.clear();
329
+ this.dirty = false;
330
+ try {
331
+ if (existsSync3(this.issuesPath)) {
332
+ unlinkSync(this.issuesPath);
333
+ }
334
+ } catch {
335
+ }
336
+ }
337
+ isDirty() {
338
+ return this.dirty;
336
339
  }
337
340
  async loadAsync() {
338
341
  try {
339
- if (await fileExists(this.findingsPath)) {
340
- const raw = await readFile2(this.findingsPath, "utf-8");
341
- const parsed = JSON.parse(raw);
342
- if (parsed?.version === FINDINGS_DATA_VERSION && Array.isArray(parsed.findings)) {
343
- for (const f of parsed.findings) {
344
- this.findings.set(f.findingId, f);
345
- }
346
- }
342
+ if (await fileExists(this.issuesPath)) {
343
+ const raw = await readFile2(this.issuesPath, "utf-8");
344
+ this.hydrate(raw);
347
345
  }
348
346
  } catch (err) {
349
- brakitDebug(`FindingStore: could not load findings file, starting fresh: ${err}`);
347
+ brakitDebug(`IssueStore: could not load issues file, starting fresh: ${err}`);
350
348
  }
351
349
  }
352
350
  /** Sync load for tests only — not used in production paths. */
353
351
  loadSync() {
354
352
  try {
355
- if (existsSync3(this.findingsPath)) {
356
- const raw = readFileSync2(this.findingsPath, "utf-8");
357
- const parsed = JSON.parse(raw);
358
- if (parsed?.version === FINDINGS_DATA_VERSION && Array.isArray(parsed.findings)) {
359
- for (const f of parsed.findings) {
360
- this.findings.set(f.findingId, f);
361
- }
362
- }
353
+ if (existsSync3(this.issuesPath)) {
354
+ const raw = readFileSync2(this.issuesPath, "utf-8");
355
+ this.hydrate(raw);
363
356
  }
364
357
  } catch (err) {
365
- brakitDebug(`FindingStore: could not load findings file, starting fresh: ${err}`);
358
+ brakitDebug(`IssueStore: could not load issues file, starting fresh: ${err}`);
359
+ }
360
+ }
361
+ /** Parse and populate issues from a raw JSON string. */
362
+ hydrate(raw) {
363
+ const validated = validateIssuesData(JSON.parse(raw));
364
+ if (!validated) return;
365
+ for (const issue of validated.issues) {
366
+ this.issues.set(issue.issueId, issue);
366
367
  }
367
368
  }
368
369
  flush() {
@@ -377,8 +378,8 @@ var FindingStore = class {
377
378
  }
378
379
  serialize() {
379
380
  const data = {
380
- version: FINDINGS_DATA_VERSION,
381
- findings: [...this.findings.values()]
381
+ version: ISSUES_DATA_VERSION,
382
+ issues: [...this.issues.values()]
382
383
  };
383
384
  return JSON.stringify(data);
384
385
  }
@@ -387,7 +388,7 @@ var FindingStore = class {
387
388
  // src/detect/project.ts
388
389
  import { readFile as readFile3, readdir } from "fs/promises";
389
390
  import { existsSync as existsSync4 } from "fs";
390
- import { join, relative } from "path";
391
+ import { join as join2, relative } from "path";
391
392
  var FRAMEWORKS = [
392
393
  { name: "nextjs", dep: "next", devCmd: "next dev", bin: "next", defaultPort: 3e3, devArgs: ["dev", "--port"] },
393
394
  { name: "remix", dep: "@remix-run/dev", devCmd: "remix dev", bin: "remix", defaultPort: 3e3, devArgs: ["dev"] },
@@ -396,24 +397,24 @@ var FRAMEWORKS = [
396
397
  { name: "astro", dep: "astro", devCmd: "astro dev", bin: "astro", defaultPort: 4321, devArgs: ["dev", "--port"] }
397
398
  ];
398
399
  async function detectProject(rootDir) {
399
- const pkgPath = join(rootDir, "package.json");
400
+ const pkgPath = join2(rootDir, "package.json");
400
401
  const raw = await readFile3(pkgPath, "utf-8");
401
402
  const pkg = JSON.parse(raw);
402
403
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
403
404
  const framework = detectFrameworkFromDeps(allDeps);
404
405
  const matched = FRAMEWORKS.find((f) => f.name === framework);
405
406
  const devCommand = matched?.devCmd ?? "";
406
- const devBin = matched ? join(rootDir, "node_modules", ".bin", matched.bin) : "";
407
+ const devBin = matched ? join2(rootDir, "node_modules", ".bin", matched.bin) : "";
407
408
  const defaultPort = matched?.defaultPort ?? 3e3;
408
409
  const packageManager = await detectPackageManager(rootDir);
409
410
  return { framework, devCommand, devBin, defaultPort, packageManager };
410
411
  }
411
412
  async function detectPackageManager(rootDir) {
412
- if (await fileExists(join(rootDir, "bun.lockb"))) return "bun";
413
- if (await fileExists(join(rootDir, "bun.lock"))) return "bun";
414
- if (await fileExists(join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
415
- if (await fileExists(join(rootDir, "yarn.lock"))) return "yarn";
416
- if (await fileExists(join(rootDir, "package-lock.json"))) return "npm";
413
+ if (await fileExists(join2(rootDir, "bun.lockb"))) return "bun";
414
+ if (await fileExists(join2(rootDir, "bun.lock"))) return "bun";
415
+ if (await fileExists(join2(rootDir, "pnpm-lock.yaml"))) return "pnpm";
416
+ if (await fileExists(join2(rootDir, "yarn.lock"))) return "yarn";
417
+ if (await fileExists(join2(rootDir, "package-lock.json"))) return "npm";
417
418
  return "unknown";
418
419
  }
419
420
  function detectFrameworkFromDeps(allDeps) {
@@ -425,8 +426,10 @@ function detectFrameworkFromDeps(allDeps) {
425
426
 
426
427
  // src/instrument/adapter-registry.ts
427
428
  var AdapterRegistry = class {
428
- adapters = [];
429
- active = [];
429
+ constructor() {
430
+ this.adapters = [];
431
+ this.active = [];
432
+ }
430
433
  register(adapter) {
431
434
  this.adapters.push(adapter);
432
435
  }
@@ -455,6 +458,38 @@ var AdapterRegistry = class {
455
458
  }
456
459
  };
457
460
 
461
+ // src/utils/response.ts
462
+ function tryParseJson(body) {
463
+ if (!body) return null;
464
+ try {
465
+ return JSON.parse(body);
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+ function unwrapResponse(parsed) {
471
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
472
+ const obj = parsed;
473
+ const keys = Object.keys(obj);
474
+ if (keys.length > 3) return parsed;
475
+ let best = null;
476
+ let bestSize = 0;
477
+ for (const key of keys) {
478
+ const val = obj[key];
479
+ if (Array.isArray(val) && val.length > bestSize) {
480
+ best = val;
481
+ bestSize = val.length;
482
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
483
+ const size = Object.keys(val).length;
484
+ if (size > bestSize) {
485
+ best = val;
486
+ bestSize = size;
487
+ }
488
+ }
489
+ }
490
+ return best && bestSize >= OVERFETCH_UNWRAP_MIN_SIZE ? best : parsed;
491
+ }
492
+
458
493
  // src/analysis/rules/patterns.ts
459
494
  var SECRET_KEYS = /^(password|passwd|secret|api_key|apiKey|api_secret|apiSecret|private_key|privateKey|client_secret|clientSecret)$/;
460
495
  var TOKEN_PARAMS = /^(token|api_key|apiKey|secret|password|access_token|session_id|sessionId)$/;
@@ -468,6 +503,8 @@ var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
468
503
  var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
469
504
  var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
470
505
  var INTERNAL_ID_SUFFIX = /Id$|_id$/;
506
+ var SELF_SERVICE_PATH = /\/(?:me|account|profile|settings|self)(?=\/|\?|#|$)/i;
507
+ var SENSITIVE_FIELD_NAMES = /^(phone|phoneNumber|phone_number|ssn|socialSecurityNumber|social_security_number|dateOfBirth|date_of_birth|dob|address|streetAddress|street_address|creditCard|credit_card|cardNumber|card_number|bankAccount|bank_account|passport|passportNumber|passport_number|nationalId|national_id)$/i;
471
508
  var SELECT_STAR_RE = /^SELECT\s+\*/i;
472
509
  var SELECT_DOT_STAR_RE = /\.\*\s+FROM/i;
473
510
  var RULE_HINTS = {
@@ -481,31 +518,35 @@ var RULE_HINTS = {
481
518
  "response-pii-leak": "API responses should return minimal data. Don't echo back full user records \u2014 select only the fields the client needs."
482
519
  };
483
520
 
484
- // src/analysis/rules/exposed-secret.ts
485
- function tryParseJson(body) {
486
- if (!body) return null;
487
- try {
488
- return JSON.parse(body);
489
- } catch {
490
- return null;
491
- }
521
+ // src/utils/http-status.ts
522
+ function isErrorStatus(code) {
523
+ return code >= 400;
524
+ }
525
+ function isServerError(code) {
526
+ return code >= 500;
527
+ }
528
+ function isRedirect(code) {
529
+ return code >= 300 && code < 400;
492
530
  }
493
- function findSecretKeys(obj, prefix) {
531
+
532
+ // src/analysis/rules/exposed-secret.ts
533
+ function findSecretKeys(obj, prefix, depth = 0) {
494
534
  const found = [];
535
+ if (depth >= MAX_OBJECT_SCAN_DEPTH) return found;
495
536
  if (!obj || typeof obj !== "object") return found;
496
537
  if (Array.isArray(obj)) {
497
- for (let i = 0; i < Math.min(obj.length, 5); i++) {
498
- found.push(...findSecretKeys(obj[i], prefix));
538
+ for (let i = 0; i < Math.min(obj.length, SECRET_SCAN_ARRAY_LIMIT); i++) {
539
+ found.push(...findSecretKeys(obj[i], prefix, depth + 1));
499
540
  }
500
541
  return found;
501
542
  }
502
543
  for (const k of Object.keys(obj)) {
503
544
  const val = obj[k];
504
- if (SECRET_KEYS.test(k) && typeof val === "string" && val.length >= 8 && !MASKED_RE.test(val)) {
545
+ if (SECRET_KEYS.test(k) && typeof val === "string" && val.length >= MIN_SECRET_VALUE_LENGTH && !MASKED_RE.test(val)) {
505
546
  found.push(k);
506
547
  }
507
548
  if (typeof val === "object" && val !== null) {
508
- found.push(...findSecretKeys(val, prefix + k + "."));
549
+ found.push(...findSecretKeys(val, prefix + k + ".", depth + 1));
509
550
  }
510
551
  }
511
552
  return found;
@@ -519,8 +560,8 @@ var exposedSecretRule = {
519
560
  const findings = [];
520
561
  const seen = /* @__PURE__ */ new Map();
521
562
  for (const r of ctx.requests) {
522
- if (r.statusCode >= 400) continue;
523
- const parsed = tryParseJson(r.responseBody);
563
+ if (isErrorStatus(r.statusCode)) continue;
564
+ const parsed = ctx.parsedBodies.response.get(r.id);
524
565
  if (!parsed) continue;
525
566
  const keys = findSecretKeys(parsed, "");
526
567
  if (keys.length === 0) continue;
@@ -673,7 +714,7 @@ var errorInfoLeakRule = {
673
714
 
674
715
  // src/analysis/rules/insecure-cookie.ts
675
716
  function isFrameworkResponse(r) {
676
- if (r.statusCode >= 300 && r.statusCode < 400) return true;
717
+ if (isRedirect(r.statusCode)) return true;
677
718
  if (r.path?.startsWith("/__")) return true;
678
719
  if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
679
720
  return false;
@@ -779,48 +820,15 @@ var corsCredentialsRule = {
779
820
  }
780
821
  };
781
822
 
782
- // src/utils/response.ts
783
- function unwrapResponse(parsed) {
784
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
785
- const obj = parsed;
786
- const keys = Object.keys(obj);
787
- if (keys.length > 3) return parsed;
788
- let best = null;
789
- let bestSize = 0;
790
- for (const key of keys) {
791
- const val = obj[key];
792
- if (Array.isArray(val) && val.length > bestSize) {
793
- best = val;
794
- bestSize = val.length;
795
- } else if (val && typeof val === "object" && !Array.isArray(val)) {
796
- const size = Object.keys(val).length;
797
- if (size > bestSize) {
798
- best = val;
799
- bestSize = size;
800
- }
801
- }
802
- }
803
- return best && bestSize >= OVERFETCH_UNWRAP_MIN_SIZE ? best : parsed;
804
- }
805
-
806
823
  // src/analysis/rules/response-pii-leak.ts
807
824
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
808
- var FULL_RECORD_MIN_FIELDS = 5;
809
- var LIST_PII_MIN_ITEMS = 2;
810
- function tryParseJson2(body) {
811
- if (!body) return null;
812
- try {
813
- return JSON.parse(body);
814
- } catch {
815
- return null;
816
- }
817
- }
818
- function findEmails(obj) {
825
+ function findEmails(obj, depth = 0) {
819
826
  const emails = [];
827
+ if (depth >= MAX_OBJECT_SCAN_DEPTH) return emails;
820
828
  if (!obj || typeof obj !== "object") return emails;
821
829
  if (Array.isArray(obj)) {
822
- for (let i = 0; i < Math.min(obj.length, 10); i++) {
823
- emails.push(...findEmails(obj[i]));
830
+ for (let i = 0; i < Math.min(obj.length, PII_SCAN_ARRAY_LIMIT); i++) {
831
+ emails.push(...findEmails(obj[i], depth + 1));
824
832
  }
825
833
  return emails;
826
834
  }
@@ -828,7 +836,7 @@ function findEmails(obj) {
828
836
  if (typeof v === "string" && EMAIL_RE.test(v)) {
829
837
  emails.push(v);
830
838
  } else if (typeof v === "object" && v !== null) {
831
- emails.push(...findEmails(v));
839
+ emails.push(...findEmails(v, depth + 1));
832
840
  }
833
841
  }
834
842
  return emails;
@@ -847,6 +855,15 @@ function hasInternalIds(obj) {
847
855
  }
848
856
  return false;
849
857
  }
858
+ function hasSensitiveFieldNames(obj, depth = 0) {
859
+ if (depth >= MAX_OBJECT_SCAN_DEPTH) return false;
860
+ if (!obj || typeof obj !== "object") return false;
861
+ if (Array.isArray(obj)) return obj.length > 0 && hasSensitiveFieldNames(obj[0], depth + 1);
862
+ for (const key of Object.keys(obj)) {
863
+ if (SENSITIVE_FIELD_NAMES.test(key)) return true;
864
+ }
865
+ return false;
866
+ }
850
867
  function detectEchoPII(method, reqBody, target) {
851
868
  if (!WRITE_METHODS.has(method) || !reqBody || typeof reqBody !== "object") return null;
852
869
  const reqEmails = findEmails(reqBody);
@@ -868,10 +885,17 @@ function detectFullRecordPII(target) {
868
885
  if (emails.length === 0) return null;
869
886
  return { reason: "full-record", emailCount: emails.length };
870
887
  }
888
+ function detectSensitiveFieldPII(target) {
889
+ const inspect = Array.isArray(target) && target.length > 0 ? target[0] : target;
890
+ if (!inspect || typeof inspect !== "object" || Array.isArray(inspect)) return null;
891
+ if (!hasSensitiveFieldNames(inspect)) return null;
892
+ if (!hasInternalIds(inspect) && topLevelFieldCount(inspect) < FULL_RECORD_MIN_FIELDS) return null;
893
+ return { reason: "sensitive-fields", emailCount: 0 };
894
+ }
871
895
  function detectListPII(target) {
872
896
  if (!Array.isArray(target) || target.length < LIST_PII_MIN_ITEMS) return null;
873
897
  let itemsWithEmail = 0;
874
- for (let i = 0; i < Math.min(target.length, 10); i++) {
898
+ for (let i = 0; i < Math.min(target.length, PII_SCAN_ARRAY_LIMIT); i++) {
875
899
  const item = target[i];
876
900
  if (item && typeof item === "object" && findEmails(item).length > 0) {
877
901
  itemsWithEmail++;
@@ -886,12 +910,13 @@ function detectListPII(target) {
886
910
  }
887
911
  function detectPII(method, reqBody, resBody) {
888
912
  const target = unwrapResponse(resBody);
889
- return detectEchoPII(method, reqBody, target) ?? detectFullRecordPII(target) ?? detectListPII(target);
913
+ return detectEchoPII(method, reqBody, target) ?? detectFullRecordPII(target) ?? detectListPII(target) ?? detectSensitiveFieldPII(target);
890
914
  }
891
915
  var REASON_LABELS = {
892
916
  echo: "echoes back PII from the request body",
893
917
  "full-record": "returns a full record with email and internal IDs",
894
- "list-pii": "returns a list of records containing email addresses"
918
+ "list-pii": "returns a list of records containing email addresses",
919
+ "sensitive-fields": "contains sensitive personal data fields (phone, SSN, date of birth, address, etc.)"
895
920
  };
896
921
  var responsePiiLeakRule = {
897
922
  id: "response-pii-leak",
@@ -902,15 +927,15 @@ var responsePiiLeakRule = {
902
927
  const findings = [];
903
928
  const seen = /* @__PURE__ */ new Map();
904
929
  for (const r of ctx.requests) {
905
- if (r.statusCode >= 400) continue;
906
- const resJson = tryParseJson2(r.responseBody);
930
+ if (isErrorStatus(r.statusCode)) continue;
931
+ if (SELF_SERVICE_PATH.test(r.path)) continue;
932
+ const resJson = ctx.parsedBodies.response.get(r.id);
907
933
  if (!resJson) continue;
908
- const reqJson = tryParseJson2(r.requestBody);
934
+ const reqJson = ctx.parsedBodies.request.get(r.id) ?? null;
909
935
  const detection = detectPII(r.method, reqJson, resJson);
910
936
  if (!detection) continue;
911
937
  const ep = `${r.method} ${r.path}`;
912
- const dedupKey = `${ep}:${detection.reason}`;
913
- const existing = seen.get(dedupKey);
938
+ const existing = seen.get(ep);
914
939
  if (existing) {
915
940
  existing.count++;
916
941
  continue;
@@ -919,12 +944,12 @@ var responsePiiLeakRule = {
919
944
  severity: "warning",
920
945
  rule: "response-pii-leak",
921
946
  title: "PII Leak in Response",
922
- desc: `${ep} \u2014 ${REASON_LABELS[detection.reason]}`,
923
- hint: this.hint,
947
+ desc: `${ep} \u2014 exposes PII in response`,
948
+ hint: `Detection: ${REASON_LABELS[detection.reason]}. ${this.hint}`,
924
949
  endpoint: ep,
925
950
  count: 1
926
951
  };
927
- seen.set(dedupKey, finding);
952
+ seen.set(ep, finding);
928
953
  findings.push(finding);
929
954
  }
930
955
  return findings;
@@ -932,12 +957,33 @@ var responsePiiLeakRule = {
932
957
  };
933
958
 
934
959
  // src/analysis/rules/scanner.ts
960
+ function buildBodyCache(requests) {
961
+ const response = /* @__PURE__ */ new Map();
962
+ 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);
967
+ }
968
+ if (r.requestBody) {
969
+ const parsed = tryParseJson(r.requestBody);
970
+ if (parsed != null) request.set(r.id, parsed);
971
+ }
972
+ }
973
+ return { response, request };
974
+ }
935
975
  var SecurityScanner = class {
936
- rules = [];
976
+ constructor() {
977
+ this.rules = [];
978
+ }
937
979
  register(rule) {
938
980
  this.rules.push(rule);
939
981
  }
940
- scan(ctx) {
982
+ scan(input) {
983
+ const ctx = {
984
+ ...input,
985
+ parsedBodies: buildBodyCache(input.requests)
986
+ };
941
987
  const findings = [];
942
988
  for (const rule of this.rules) {
943
989
  try {
@@ -966,7 +1012,9 @@ function createDefaultScanner() {
966
1012
 
967
1013
  // src/core/disposable.ts
968
1014
  var SubscriptionBag = class {
969
- items = [];
1015
+ constructor() {
1016
+ this.items = [];
1017
+ }
970
1018
  add(teardown) {
971
1019
  this.items.push(typeof teardown === "function" ? { dispose: teardown } : teardown);
972
1020
  }
@@ -979,6 +1027,41 @@ var SubscriptionBag = class {
979
1027
  // src/analysis/group.ts
980
1028
  import { randomUUID } from "crypto";
981
1029
 
1030
+ // src/constants/routes.ts
1031
+ var DASHBOARD_PREFIX = "/__brakit";
1032
+ var DASHBOARD_API_REQUESTS = `${DASHBOARD_PREFIX}/api/requests`;
1033
+ var DASHBOARD_API_EVENTS = `${DASHBOARD_PREFIX}/api/events`;
1034
+ var DASHBOARD_API_FLOWS = `${DASHBOARD_PREFIX}/api/flows`;
1035
+ var DASHBOARD_API_CLEAR = `${DASHBOARD_PREFIX}/api/clear`;
1036
+ var DASHBOARD_API_LOGS = `${DASHBOARD_PREFIX}/api/logs`;
1037
+ var DASHBOARD_API_FETCHES = `${DASHBOARD_PREFIX}/api/fetches`;
1038
+ var DASHBOARD_API_ERRORS = `${DASHBOARD_PREFIX}/api/errors`;
1039
+ var DASHBOARD_API_QUERIES = `${DASHBOARD_PREFIX}/api/queries`;
1040
+ var DASHBOARD_API_INGEST = `${DASHBOARD_PREFIX}/api/ingest`;
1041
+ var DASHBOARD_API_METRICS = `${DASHBOARD_PREFIX}/api/metrics`;
1042
+ var DASHBOARD_API_ACTIVITY = `${DASHBOARD_PREFIX}/api/activity`;
1043
+ var DASHBOARD_API_METRICS_LIVE = `${DASHBOARD_PREFIX}/api/metrics/live`;
1044
+ var DASHBOARD_API_INSIGHTS = `${DASHBOARD_PREFIX}/api/insights`;
1045
+ var DASHBOARD_API_SECURITY = `${DASHBOARD_PREFIX}/api/security`;
1046
+ var DASHBOARD_API_TAB = `${DASHBOARD_PREFIX}/api/tab`;
1047
+ var DASHBOARD_API_FINDINGS = `${DASHBOARD_PREFIX}/api/findings`;
1048
+ var DASHBOARD_API_FINDINGS_REPORT = `${DASHBOARD_PREFIX}/api/findings/report`;
1049
+ var VALID_TABS_TUPLE = [
1050
+ "overview",
1051
+ "actions",
1052
+ "requests",
1053
+ "fetches",
1054
+ "queries",
1055
+ "errors",
1056
+ "logs",
1057
+ "performance",
1058
+ "security"
1059
+ ];
1060
+ var VALID_TABS = new Set(VALID_TABS_TUPLE);
1061
+
1062
+ // src/constants/network.ts
1063
+ var RECOVERY_WINDOW_MS = 5 * 60 * 1e3;
1064
+
982
1065
  // src/analysis/categorize.ts
983
1066
  function detectCategory(req) {
984
1067
  const { method, url, statusCode, responseHeaders } = req;
@@ -1042,7 +1125,7 @@ function labelRequest(req) {
1042
1125
  function generateHumanLabel(req, category) {
1043
1126
  const effectivePath = getEffectivePath(req);
1044
1127
  const endpointName = getEndpointName(effectivePath);
1045
- const failed = req.statusCode >= 400;
1128
+ const failed = isErrorStatus(req.statusCode);
1046
1129
  switch (category) {
1047
1130
  case "auth-handshake":
1048
1131
  return "Auth handshake";
@@ -1222,7 +1305,7 @@ function detectWarnings(requests) {
1222
1305
  for (const req of slowRequests) {
1223
1306
  warnings.push(`${req.label} took ${(req.durationMs / 1e3).toFixed(1)}s`);
1224
1307
  }
1225
- const errors = requests.filter((r) => r.statusCode >= 500);
1308
+ const errors = requests.filter((r) => isServerError(r.statusCode));
1226
1309
  for (const req of errors) {
1227
1310
  warnings.push(`${req.label} \u2014 server error (${req.statusCode})`);
1228
1311
  }
@@ -1278,7 +1361,7 @@ function buildFlow(rawRequests) {
1278
1361
  requests,
1279
1362
  startTime,
1280
1363
  totalDurationMs: Math.round(endTime - startTime),
1281
- hasErrors: requests.some((r) => r.statusCode >= 400),
1364
+ hasErrors: requests.some((r) => isErrorStatus(r.statusCode)),
1282
1365
  warnings: detectWarnings(rawRequests),
1283
1366
  sourcePage,
1284
1367
  redundancyPct
@@ -1346,8 +1429,14 @@ function groupBy(items, keyFn) {
1346
1429
  }
1347
1430
 
1348
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
+ }
1349
1438
  function getEndpointKey(method, path) {
1350
- return `${method} ${path}`;
1439
+ return `${method} ${normalizePath(path)}`;
1351
1440
  }
1352
1441
  var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
1353
1442
  function extractEndpointFromDesc(desc) {
@@ -1429,6 +1518,15 @@ function windowByEndpoint(requests) {
1429
1518
  }
1430
1519
  return windowed;
1431
1520
  }
1521
+ function extractActiveEndpoints(requests) {
1522
+ 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
+ }
1527
+ }
1528
+ return endpoints;
1529
+ }
1432
1530
  function prepareContext(ctx) {
1433
1531
  const nonStatic = ctx.requests.filter(
1434
1532
  (r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
@@ -1446,7 +1544,7 @@ function prepareContext(ctx) {
1446
1544
  endpointGroups.set(ep, g);
1447
1545
  }
1448
1546
  g.total++;
1449
- if (r.statusCode >= 400) g.errors++;
1547
+ if (isErrorStatus(r.statusCode)) g.errors++;
1450
1548
  g.totalDuration += r.durationMs;
1451
1549
  g.totalSize += r.responseSize ?? 0;
1452
1550
  const reqQueries = queriesByReq.get(r.id) ?? [];
@@ -1481,7 +1579,9 @@ function prepareContext(ctx) {
1481
1579
  // src/analysis/insights/runner.ts
1482
1580
  var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
1483
1581
  var InsightRunner = class {
1484
- rules = [];
1582
+ constructor() {
1583
+ this.rules = [];
1584
+ }
1485
1585
  register(rule) {
1486
1586
  this.rules.push(rule);
1487
1587
  }
@@ -1866,7 +1966,7 @@ var responseOverfetchRule = {
1866
1966
  const insights = [];
1867
1967
  const seen = /* @__PURE__ */ new Set();
1868
1968
  for (const r of ctx.nonStatic) {
1869
- if (r.statusCode >= 400 || !r.responseBody) continue;
1969
+ if (isErrorStatus(r.statusCode) || !r.responseBody) continue;
1870
1970
  const ep = getEndpointKey(r.method, r.path);
1871
1971
  if (seen.has(ep)) continue;
1872
1972
  let parsed;
@@ -2010,96 +2110,49 @@ function computeInsights(ctx) {
2010
2110
  return createDefaultInsightRunner().run(ctx);
2011
2111
  }
2012
2112
 
2013
- // src/analysis/insight-tracker.ts
2014
- function computeInsightKey(insight) {
2015
- const identifier = extractEndpointFromDesc(insight.desc) ?? insight.title;
2016
- return `${insight.type}:${identifier}`;
2017
- }
2018
- function enrichedIdFromInsight(insight) {
2019
- return computeInsightId(insight.type, insight.nav ?? "global", insight.desc);
2020
- }
2021
- var InsightTracker = class {
2022
- tracked = /* @__PURE__ */ new Map();
2023
- enrichedIndex = /* @__PURE__ */ new Map();
2024
- reconcile(current) {
2025
- const currentKeys = /* @__PURE__ */ new Set();
2026
- const now = Date.now();
2027
- for (const insight of current) {
2028
- const key = computeInsightKey(insight);
2029
- currentKeys.add(key);
2030
- const existing = this.tracked.get(key);
2031
- this.enrichedIndex.set(enrichedIdFromInsight(insight), key);
2032
- if (existing) {
2033
- existing.insight = insight;
2034
- existing.lastSeenAt = now;
2035
- existing.consecutiveAbsences = 0;
2036
- if (existing.state === "resolved") {
2037
- existing.state = "open";
2038
- existing.resolvedAt = null;
2039
- }
2040
- } else {
2041
- this.tracked.set(key, {
2042
- key,
2043
- state: "open",
2044
- insight,
2045
- firstSeenAt: now,
2046
- lastSeenAt: now,
2047
- resolvedAt: null,
2048
- consecutiveAbsences: 0,
2049
- aiStatus: null,
2050
- aiNotes: null
2051
- });
2052
- }
2053
- }
2054
- for (const [, stateful] of this.tracked) {
2055
- if ((stateful.state === "open" || stateful.state === "fixing") && !currentKeys.has(stateful.key)) {
2056
- stateful.consecutiveAbsences++;
2057
- if (stateful.consecutiveAbsences >= RESOLVE_AFTER_ABSENCES) {
2058
- stateful.state = "resolved";
2059
- stateful.resolvedAt = now;
2060
- }
2061
- } else if (stateful.state === "resolved" && stateful.resolvedAt !== null && now - stateful.resolvedAt > RESOLVED_INSIGHT_TTL_MS) {
2062
- this.tracked.delete(stateful.key);
2063
- this.enrichedIndex.delete(enrichedIdFromInsight(stateful.insight));
2064
- }
2065
- }
2066
- return [...this.tracked.values()];
2067
- }
2068
- reportFix(enrichedId, status, notes) {
2069
- const key = this.enrichedIndex.get(enrichedId);
2070
- if (!key) return false;
2071
- const stateful = this.tracked.get(key);
2072
- if (!stateful) return false;
2073
- stateful.aiStatus = status;
2074
- stateful.aiNotes = notes;
2075
- if (status === "fixed") {
2076
- stateful.state = "fixing";
2077
- }
2078
- return true;
2079
- }
2080
- getAll() {
2081
- return [...this.tracked.values()];
2082
- }
2083
- clear() {
2084
- this.tracked.clear();
2085
- this.enrichedIndex.clear();
2086
- }
2087
- };
2113
+ // src/analysis/issue-mappers.ts
2114
+ function categorizeInsight(type) {
2115
+ if (type === "security") return "security";
2116
+ if (type === "error" || type === "error-hotspot") return "reliability";
2117
+ return "performance";
2118
+ }
2119
+ function insightToIssue(insight) {
2120
+ return {
2121
+ category: categorizeInsight(insight.type),
2122
+ rule: insight.type,
2123
+ severity: insight.severity,
2124
+ title: insight.title,
2125
+ desc: insight.desc,
2126
+ hint: insight.hint,
2127
+ detail: insight.detail,
2128
+ endpoint: extractEndpointFromDesc(insight.desc) ?? void 0,
2129
+ nav: insight.nav
2130
+ };
2131
+ }
2132
+ function securityFindingToIssue(finding) {
2133
+ return {
2134
+ category: "security",
2135
+ rule: finding.rule,
2136
+ severity: finding.severity,
2137
+ title: finding.title,
2138
+ desc: finding.desc,
2139
+ hint: finding.hint,
2140
+ endpoint: finding.endpoint,
2141
+ nav: "security"
2142
+ };
2143
+ }
2088
2144
 
2089
2145
  // src/analysis/engine.ts
2090
2146
  var AnalysisEngine = class {
2091
2147
  constructor(registry, debounceMs = ANALYSIS_DEBOUNCE_MS) {
2092
2148
  this.registry = registry;
2093
2149
  this.debounceMs = debounceMs;
2150
+ this.cachedInsights = [];
2151
+ this.cachedFindings = [];
2152
+ this.debounceTimer = null;
2153
+ this.subs = new SubscriptionBag();
2094
2154
  this.scanner = createDefaultScanner();
2095
2155
  }
2096
- scanner;
2097
- insightTracker = new InsightTracker();
2098
- cachedInsights = [];
2099
- cachedFindings = [];
2100
- cachedStatefulInsights = [];
2101
- debounceTimer = null;
2102
- subs = new SubscriptionBag();
2103
2156
  start() {
2104
2157
  const bus = this.registry.get("event-bus");
2105
2158
  this.subs.add(bus.on("request:completed", () => this.scheduleRecompute()));
@@ -2120,15 +2173,6 @@ var AnalysisEngine = class {
2120
2173
  getFindings() {
2121
2174
  return this.cachedFindings;
2122
2175
  }
2123
- getStatefulFindings() {
2124
- return this.registry.has("finding-store") ? this.registry.get("finding-store").getAll() : [];
2125
- }
2126
- getStatefulInsights() {
2127
- return this.insightTracker.getAll();
2128
- }
2129
- reportInsightFix(enrichedId, status, notes) {
2130
- return this.insightTracker.reportFix(enrichedId, status, notes);
2131
- }
2132
2176
  scheduleRecompute() {
2133
2177
  if (this.debounceTimer) return;
2134
2178
  this.debounceTimer = setTimeout(() => {
@@ -2137,20 +2181,14 @@ var AnalysisEngine = class {
2137
2181
  }, this.debounceMs);
2138
2182
  }
2139
2183
  recompute() {
2140
- const requests = this.registry.get("request-store").getAll();
2184
+ const allRequests = this.registry.get("request-store").getAll();
2141
2185
  const queries = this.registry.get("query-store").getAll();
2142
2186
  const errors = this.registry.get("error-store").getAll();
2143
2187
  const logs = this.registry.get("log-store").getAll();
2144
2188
  const fetches = this.registry.get("fetch-store").getAll();
2189
+ const requests = windowByEndpoint(allRequests);
2145
2190
  const flows = groupRequestsIntoFlows(requests);
2146
2191
  this.cachedFindings = this.scanner.scan({ requests, logs });
2147
- if (this.registry.has("finding-store")) {
2148
- const findingStore = this.registry.get("finding-store");
2149
- for (const finding of this.cachedFindings) {
2150
- findingStore.upsert(finding, "passive");
2151
- }
2152
- findingStore.reconcilePassive(this.cachedFindings);
2153
- }
2154
2192
  this.cachedInsights = computeInsights({
2155
2193
  requests,
2156
2194
  queries,
@@ -2160,24 +2198,40 @@ var AnalysisEngine = class {
2160
2198
  previousMetrics: this.registry.get("metrics-store").getAll(),
2161
2199
  securityFindings: this.cachedFindings
2162
2200
  });
2163
- this.cachedStatefulInsights = this.insightTracker.reconcile(this.cachedInsights);
2164
- const update = {
2165
- insights: this.cachedInsights,
2166
- findings: this.cachedFindings,
2167
- statefulFindings: this.getStatefulFindings(),
2168
- statefulInsights: this.cachedStatefulInsights
2169
- };
2170
- this.registry.get("event-bus").emit("analysis:updated", update);
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
+ }
2171
2225
  }
2172
2226
  };
2173
2227
 
2174
2228
  // src/index.ts
2175
- var VERSION = "0.8.5";
2229
+ var VERSION = "0.8.7";
2176
2230
  export {
2177
2231
  AdapterRegistry,
2178
2232
  AnalysisEngine,
2179
- FindingStore,
2180
2233
  InsightRunner,
2234
+ IssueStore,
2181
2235
  SecurityScanner,
2182
2236
  VERSION,
2183
2237
  computeInsights,