@lbroth/rothunter 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/adapters/llm.d.ts +68 -0
  4. package/dist/adapters/llm.d.ts.map +1 -0
  5. package/dist/adapters/llm.js +189 -0
  6. package/dist/adapters/llm.js.map +1 -0
  7. package/dist/config.d.ts +37 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +81 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/detector-registry.d.ts +32 -0
  12. package/dist/detector-registry.d.ts.map +1 -0
  13. package/dist/detector-registry.js +74 -0
  14. package/dist/detector-registry.js.map +1 -0
  15. package/dist/detectors/api-race.d.ts +6 -0
  16. package/dist/detectors/api-race.d.ts.map +1 -0
  17. package/dist/detectors/api-race.js +222 -0
  18. package/dist/detectors/api-race.js.map +1 -0
  19. package/dist/detectors/bad-config.d.ts +6 -0
  20. package/dist/detectors/bad-config.d.ts.map +1 -0
  21. package/dist/detectors/bad-config.js +529 -0
  22. package/dist/detectors/bad-config.js.map +1 -0
  23. package/dist/detectors/console-log-prod.d.ts +6 -0
  24. package/dist/detectors/console-log-prod.d.ts.map +1 -0
  25. package/dist/detectors/console-log-prod.js +72 -0
  26. package/dist/detectors/console-log-prod.js.map +1 -0
  27. package/dist/detectors/dead-api.d.ts +10 -0
  28. package/dist/detectors/dead-api.d.ts.map +1 -0
  29. package/dist/detectors/dead-api.js +115 -0
  30. package/dist/detectors/dead-api.js.map +1 -0
  31. package/dist/detectors/dead-export.d.ts +12 -0
  32. package/dist/detectors/dead-export.d.ts.map +1 -0
  33. package/dist/detectors/dead-export.js +140 -0
  34. package/dist/detectors/dead-export.js.map +1 -0
  35. package/dist/detectors/dead-handler.d.ts +12 -0
  36. package/dist/detectors/dead-handler.d.ts.map +1 -0
  37. package/dist/detectors/dead-handler.js +40 -0
  38. package/dist/detectors/dead-handler.js.map +1 -0
  39. package/dist/detectors/dead-module.d.ts +14 -0
  40. package/dist/detectors/dead-module.d.ts.map +1 -0
  41. package/dist/detectors/dead-module.js +50 -0
  42. package/dist/detectors/dead-module.js.map +1 -0
  43. package/dist/detectors/deep-nesting.d.ts +12 -0
  44. package/dist/detectors/deep-nesting.d.ts.map +1 -0
  45. package/dist/detectors/deep-nesting.js +133 -0
  46. package/dist/detectors/deep-nesting.js.map +1 -0
  47. package/dist/detectors/duplicate-function.d.ts +9 -0
  48. package/dist/detectors/duplicate-function.d.ts.map +1 -0
  49. package/dist/detectors/duplicate-function.js +199 -0
  50. package/dist/detectors/duplicate-function.js.map +1 -0
  51. package/dist/detectors/duplicate-type.d.ts +9 -0
  52. package/dist/detectors/duplicate-type.d.ts.map +1 -0
  53. package/dist/detectors/duplicate-type.js +166 -0
  54. package/dist/detectors/duplicate-type.js.map +1 -0
  55. package/dist/detectors/hot-hub-file.d.ts +11 -0
  56. package/dist/detectors/hot-hub-file.d.ts.map +1 -0
  57. package/dist/detectors/hot-hub-file.js +42 -0
  58. package/dist/detectors/hot-hub-file.js.map +1 -0
  59. package/dist/detectors/long-file.d.ts +12 -0
  60. package/dist/detectors/long-file.d.ts.map +1 -0
  61. package/dist/detectors/long-file.js +82 -0
  62. package/dist/detectors/long-file.js.map +1 -0
  63. package/dist/detectors/long-function.d.ts +12 -0
  64. package/dist/detectors/long-function.d.ts.map +1 -0
  65. package/dist/detectors/long-function.js +45 -0
  66. package/dist/detectors/long-function.js.map +1 -0
  67. package/dist/detectors/magic-numbers.d.ts +10 -0
  68. package/dist/detectors/magic-numbers.d.ts.map +1 -0
  69. package/dist/detectors/magic-numbers.js +332 -0
  70. package/dist/detectors/magic-numbers.js.map +1 -0
  71. package/dist/detectors/mutable-globals.d.ts +6 -0
  72. package/dist/detectors/mutable-globals.d.ts.map +1 -0
  73. package/dist/detectors/mutable-globals.js +95 -0
  74. package/dist/detectors/mutable-globals.js.map +1 -0
  75. package/dist/detectors/mutation.d.ts +11 -0
  76. package/dist/detectors/mutation.d.ts.map +1 -0
  77. package/dist/detectors/mutation.js +397 -0
  78. package/dist/detectors/mutation.js.map +1 -0
  79. package/dist/detectors/public-any.d.ts +6 -0
  80. package/dist/detectors/public-any.d.ts.map +1 -0
  81. package/dist/detectors/public-any.js +52 -0
  82. package/dist/detectors/public-any.js.map +1 -0
  83. package/dist/detectors/race-condition.d.ts +6 -0
  84. package/dist/detectors/race-condition.d.ts.map +1 -0
  85. package/dist/detectors/race-condition.js +608 -0
  86. package/dist/detectors/race-condition.js.map +1 -0
  87. package/dist/detectors/shared-db-write.d.ts +6 -0
  88. package/dist/detectors/shared-db-write.d.ts.map +1 -0
  89. package/dist/detectors/shared-db-write.js +656 -0
  90. package/dist/detectors/shared-db-write.js.map +1 -0
  91. package/dist/detectors/silent-catch.d.ts +6 -0
  92. package/dist/detectors/silent-catch.d.ts.map +1 -0
  93. package/dist/detectors/silent-catch.js +167 -0
  94. package/dist/detectors/silent-catch.js.map +1 -0
  95. package/dist/detectors/similar-functions.d.ts +15 -0
  96. package/dist/detectors/similar-functions.d.ts.map +1 -0
  97. package/dist/detectors/similar-functions.js +334 -0
  98. package/dist/detectors/similar-functions.js.map +1 -0
  99. package/dist/detectors/skip-tests.d.ts +6 -0
  100. package/dist/detectors/skip-tests.d.ts.map +1 -0
  101. package/dist/detectors/skip-tests.js +69 -0
  102. package/dist/detectors/skip-tests.js.map +1 -0
  103. package/dist/detectors/todo-comments.d.ts +29 -0
  104. package/dist/detectors/todo-comments.d.ts.map +1 -0
  105. package/dist/detectors/todo-comments.js +154 -0
  106. package/dist/detectors/todo-comments.js.map +1 -0
  107. package/dist/detectors/unused-deps.d.ts +8 -0
  108. package/dist/detectors/unused-deps.d.ts.map +1 -0
  109. package/dist/detectors/unused-deps.js +115 -0
  110. package/dist/detectors/unused-deps.js.map +1 -0
  111. package/dist/extraction/api-race-confirmer.d.ts +31 -0
  112. package/dist/extraction/api-race-confirmer.d.ts.map +1 -0
  113. package/dist/extraction/api-race-confirmer.js +110 -0
  114. package/dist/extraction/api-race-confirmer.js.map +1 -0
  115. package/dist/extraction/llm-confirmer.d.ts +25 -0
  116. package/dist/extraction/llm-confirmer.d.ts.map +1 -0
  117. package/dist/extraction/llm-confirmer.js +118 -0
  118. package/dist/extraction/llm-confirmer.js.map +1 -0
  119. package/dist/extraction/mutation-confirmer.d.ts +30 -0
  120. package/dist/extraction/mutation-confirmer.d.ts.map +1 -0
  121. package/dist/extraction/mutation-confirmer.js +73 -0
  122. package/dist/extraction/mutation-confirmer.js.map +1 -0
  123. package/dist/extraction/prompt-chunking.d.ts +37 -0
  124. package/dist/extraction/prompt-chunking.d.ts.map +1 -0
  125. package/dist/extraction/prompt-chunking.js +61 -0
  126. package/dist/extraction/prompt-chunking.js.map +1 -0
  127. package/dist/extraction/race-confirmer.d.ts +28 -0
  128. package/dist/extraction/race-confirmer.d.ts.map +1 -0
  129. package/dist/extraction/race-confirmer.js +68 -0
  130. package/dist/extraction/race-confirmer.js.map +1 -0
  131. package/dist/extraction/shared-db-write-confirmer.d.ts +31 -0
  132. package/dist/extraction/shared-db-write-confirmer.d.ts.map +1 -0
  133. package/dist/extraction/shared-db-write-confirmer.js +141 -0
  134. package/dist/extraction/shared-db-write-confirmer.js.map +1 -0
  135. package/dist/extraction/triage-confirmer.d.ts +59 -0
  136. package/dist/extraction/triage-confirmer.d.ts.map +1 -0
  137. package/dist/extraction/triage-confirmer.js +104 -0
  138. package/dist/extraction/triage-confirmer.js.map +1 -0
  139. package/dist/graph/cfg.d.ts +45 -0
  140. package/dist/graph/cfg.d.ts.map +1 -0
  141. package/dist/graph/cfg.js +198 -0
  142. package/dist/graph/cfg.js.map +1 -0
  143. package/dist/graph/decorator-entries.d.ts +2 -0
  144. package/dist/graph/decorator-entries.d.ts.map +1 -0
  145. package/dist/graph/decorator-entries.js +89 -0
  146. package/dist/graph/decorator-entries.js.map +1 -0
  147. package/dist/graph/entry-points.d.ts +12 -0
  148. package/dist/graph/entry-points.d.ts.map +1 -0
  149. package/dist/graph/entry-points.js +282 -0
  150. package/dist/graph/entry-points.js.map +1 -0
  151. package/dist/graph/handler-conventions.d.ts +2 -0
  152. package/dist/graph/handler-conventions.d.ts.map +1 -0
  153. package/dist/graph/handler-conventions.js +26 -0
  154. package/dist/graph/handler-conventions.js.map +1 -0
  155. package/dist/graph/iac-entries.d.ts +2 -0
  156. package/dist/graph/iac-entries.d.ts.map +1 -0
  157. package/dist/graph/iac-entries.js +123 -0
  158. package/dist/graph/iac-entries.js.map +1 -0
  159. package/dist/graph/import-graph.d.ts +48 -0
  160. package/dist/graph/import-graph.d.ts.map +1 -0
  161. package/dist/graph/import-graph.js +86 -0
  162. package/dist/graph/import-graph.js.map +1 -0
  163. package/dist/graph/monorepo-detect.d.ts +3 -0
  164. package/dist/graph/monorepo-detect.d.ts.map +1 -0
  165. package/dist/graph/monorepo-detect.js +166 -0
  166. package/dist/graph/monorepo-detect.js.map +1 -0
  167. package/dist/graph/tsconfig-paths.d.ts +23 -0
  168. package/dist/graph/tsconfig-paths.d.ts.map +1 -0
  169. package/dist/graph/tsconfig-paths.js +217 -0
  170. package/dist/graph/tsconfig-paths.js.map +1 -0
  171. package/dist/multi-workspace-scanner.d.ts +13 -0
  172. package/dist/multi-workspace-scanner.d.ts.map +1 -0
  173. package/dist/multi-workspace-scanner.js +130 -0
  174. package/dist/multi-workspace-scanner.js.map +1 -0
  175. package/dist/normalizers/type-normalizer.d.ts +16 -0
  176. package/dist/normalizers/type-normalizer.d.ts.map +1 -0
  177. package/dist/normalizers/type-normalizer.js +189 -0
  178. package/dist/normalizers/type-normalizer.js.map +1 -0
  179. package/dist/parsers/typescript-parser.d.ts +57 -0
  180. package/dist/parsers/typescript-parser.d.ts.map +1 -0
  181. package/dist/parsers/typescript-parser.js +502 -0
  182. package/dist/parsers/typescript-parser.js.map +1 -0
  183. package/dist/reporter/json-reporter.d.ts +12 -0
  184. package/dist/reporter/json-reporter.d.ts.map +1 -0
  185. package/dist/reporter/json-reporter.js +28 -0
  186. package/dist/reporter/json-reporter.js.map +1 -0
  187. package/dist/reporter/markdown-reporter.d.ts +11 -0
  188. package/dist/reporter/markdown-reporter.d.ts.map +1 -0
  189. package/dist/reporter/markdown-reporter.js +77 -0
  190. package/dist/reporter/markdown-reporter.js.map +1 -0
  191. package/dist/rothunter.d.ts +125 -0
  192. package/dist/rothunter.d.ts.map +1 -0
  193. package/dist/rothunter.js +1038 -0
  194. package/dist/rothunter.js.map +1 -0
  195. package/dist/server/false-positives.d.ts +34 -0
  196. package/dist/server/false-positives.d.ts.map +1 -0
  197. package/dist/server/false-positives.js +85 -0
  198. package/dist/server/false-positives.js.map +1 -0
  199. package/dist/server/index.d.ts +2 -0
  200. package/dist/server/index.d.ts.map +1 -0
  201. package/dist/server/index.js +1529 -0
  202. package/dist/server/index.js.map +1 -0
  203. package/dist/server/marked-to-fix.d.ts +16 -0
  204. package/dist/server/marked-to-fix.d.ts.map +1 -0
  205. package/dist/server/marked-to-fix.js +36 -0
  206. package/dist/server/marked-to-fix.js.map +1 -0
  207. package/dist/server/scan-store.d.ts +147 -0
  208. package/dist/server/scan-store.d.ts.map +1 -0
  209. package/dist/server/scan-store.js +291 -0
  210. package/dist/server/scan-store.js.map +1 -0
  211. package/dist/server/settings-store.d.ts +28 -0
  212. package/dist/server/settings-store.d.ts.map +1 -0
  213. package/dist/server/settings-store.js +46 -0
  214. package/dist/server/settings-store.js.map +1 -0
  215. package/dist/server/workspace-store.d.ts +39 -0
  216. package/dist/server/workspace-store.d.ts.map +1 -0
  217. package/dist/server/workspace-store.js +108 -0
  218. package/dist/server/workspace-store.js.map +1 -0
  219. package/dist/types/detector-input.d.ts +37 -0
  220. package/dist/types/detector-input.d.ts.map +1 -0
  221. package/dist/types/detector-input.js +2 -0
  222. package/dist/types/detector-input.js.map +1 -0
  223. package/dist/types.d.ts +110 -0
  224. package/dist/types.d.ts.map +1 -0
  225. package/dist/types.js +2 -0
  226. package/dist/types.js.map +1 -0
  227. package/dist/utils/clustering.d.ts +14 -0
  228. package/dist/utils/clustering.d.ts.map +1 -0
  229. package/dist/utils/clustering.js +56 -0
  230. package/dist/utils/clustering.js.map +1 -0
  231. package/dist/utils/gitignore.d.ts +32 -0
  232. package/dist/utils/gitignore.d.ts.map +1 -0
  233. package/dist/utils/gitignore.js +122 -0
  234. package/dist/utils/gitignore.js.map +1 -0
  235. package/dist/utils/hash.d.ts +11 -0
  236. package/dist/utils/hash.d.ts.map +1 -0
  237. package/dist/utils/hash.js +14 -0
  238. package/dist/utils/hash.js.map +1 -0
  239. package/dist/utils/ignore-annotation.d.ts +28 -0
  240. package/dist/utils/ignore-annotation.d.ts.map +1 -0
  241. package/dist/utils/ignore-annotation.js +46 -0
  242. package/dist/utils/ignore-annotation.js.map +1 -0
  243. package/dist/utils/llm-json.d.ts +2 -0
  244. package/dist/utils/llm-json.d.ts.map +1 -0
  245. package/dist/utils/llm-json.js +53 -0
  246. package/dist/utils/llm-json.js.map +1 -0
  247. package/dist/utils/logger.d.ts +3 -0
  248. package/dist/utils/logger.d.ts.map +1 -0
  249. package/dist/utils/logger.js +4 -0
  250. package/dist/utils/logger.js.map +1 -0
  251. package/dist/utils/project-conventions.d.ts +2 -0
  252. package/dist/utils/project-conventions.d.ts.map +1 -0
  253. package/dist/utils/project-conventions.js +108 -0
  254. package/dist/utils/project-conventions.js.map +1 -0
  255. package/dist/utils/regex.d.ts +9 -0
  256. package/dist/utils/regex.d.ts.map +1 -0
  257. package/dist/utils/regex.js +11 -0
  258. package/dist/utils/regex.js.map +1 -0
  259. package/dist/utils/snippet.d.ts +20 -0
  260. package/dist/utils/snippet.d.ts.map +1 -0
  261. package/dist/utils/snippet.js +28 -0
  262. package/dist/utils/snippet.js.map +1 -0
  263. package/dist/utils/source-reader.d.ts +19 -0
  264. package/dist/utils/source-reader.d.ts.map +1 -0
  265. package/dist/utils/source-reader.js +32 -0
  266. package/dist/utils/source-reader.js.map +1 -0
  267. package/logo.png +0 -0
  268. package/package.json +92 -0
  269. package/scripts/start-llm.mjs +161 -0
@@ -0,0 +1,1529 @@
1
+ // Fastify HTTP API + SSE scan stream + static UI host.
2
+ // Scans + findings persist under <workspace>/.rothunter/ (one JSON file per scan).
3
+ import Fastify from 'fastify';
4
+ import * as path from 'node:path';
5
+ import * as fs from 'node:fs/promises';
6
+ import * as os from 'node:os';
7
+ import { existsSync, statSync } from 'node:fs';
8
+ import { RotHunter } from '../rothunter.js';
9
+ import { createDefaultLlmClient } from '../adapters/llm.js';
10
+ import { TypeScriptParser } from '../parsers/typescript-parser.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { DETECTOR_IDS as ALL_DETECTORS } from '../detector-registry.js';
13
+ import { FS_ALLOW_ROOTS, isUnderAllowRoot, initWorkspaceStore, getWorkspaceRoot, setWorkspaceRoot, getRecentWorkspaces, persistCurrentWorkspace, readPersistedWorkspace, } from './workspace-store.js';
14
+ import { readSettings, writeSettings } from './settings-store.js';
15
+ import { readFalsePositives, writeFalsePositives, splitFalsePositives, readKeptOpen, writeKeptOpen, } from './false-positives.js';
16
+ import { readMarkedToFix, writeMarkedToFix } from './marked-to-fix.js';
17
+ const PORT = Number(process.env.ROTHUNTER_PORT ?? 3000);
18
+ // Loopback by default — `npm run server` on the host should not expose the
19
+ // filesystem-reaching endpoints to the LAN. Docker sets ROTHUNTER_HOST=0.0.0.0
20
+ // explicitly when LAN exposure is intended.
21
+ const HOST = process.env.ROTHUNTER_HOST ?? '127.0.0.1';
22
+ // Boot-time workspace selection. Default `/workspace` matches the Docker
23
+ // mount; in dev mode (`npm run rothunter:dev` on the host) `/workspace`
24
+ // does not exist, so fall back to the current working directory. The
25
+ // active workspace is mutable via POST /api/workspace and persists in
26
+ // ~/.rothunter/workspace.json — never inside the workspace itself, since
27
+ // changing the workspace would otherwise lose the pointer.
28
+ const bootCandidate = process.env.ROTHUNTER_WORKSPACE ??
29
+ readPersistedWorkspace()?.current ??
30
+ (existsSync('/workspace') ? '/workspace' : process.cwd());
31
+ if (!isUnderAllowRoot(bootCandidate)) {
32
+ // Two failure modes:
33
+ // 1. cwd outside roots — operator probably ran `npm run server` from
34
+ // somewhere unexpected. Hard-fail with guidance so the workspace
35
+ // switch endpoint isn't immediately broken too.
36
+ // 2. persisted/env workspace outside roots — same fix, same exit.
37
+ logger.error({ candidate: bootCandidate, allowRoots: FS_ALLOW_ROOTS }, 'Boot workspace outside ROTHUNTER_FS_ROOTS. Set ROTHUNTER_FS_ROOTS or run from inside one of the allow-roots.');
38
+ process.exit(1);
39
+ }
40
+ initWorkspaceStore(bootCandidate);
41
+ const SETTINGS = readSettings();
42
+ const UI_DIST = path.resolve(import.meta.dirname, '../ui/dist');
43
+ import { scans, sseClients, cancelledScans, scanHistoryCache, SCAN_QUEUE_LIMIT, evictOldScans, acquireScanSlot, releaseScanSlot, dropQueuedScan, broadcast, applyProgressToRecord, persistScan, loadScanHistory, getRunningScanId, getScanQueueLength, summarizeLlmStats, } from './scan-store.js';
44
+ let parseCache = null;
45
+ async function getOrParseWorkspace() {
46
+ if (parseCache && Date.now() - parseCache.parsedAt < 5 * 60_000) {
47
+ return parseCache.result;
48
+ }
49
+ const parser = new TypeScriptParser();
50
+ const result = await parser.parseWorkspaceFull({ workspaceRoot: getWorkspaceRoot() });
51
+ parseCache = { parsedAt: Date.now(), result };
52
+ return result;
53
+ }
54
+ function invalidateParseCache() {
55
+ parseCache = null;
56
+ }
57
+ // Per-scan abort controllers. Populated when a scan promotes to running,
58
+ // flipped by the cancel endpoint, removed on scan completion. Replaces
59
+ // the old "throw inside onProgress" cancellation channel (which emit()
60
+ // swallowed, so cancels were ignored by the LLM worker pool).
61
+ const abortControllers = new Map();
62
+ async function startScan(opts) {
63
+ const scanId = `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
64
+ const record = {
65
+ scanId,
66
+ workspaceRoot: opts.workspaceRoot,
67
+ state: 'queued',
68
+ detectorsAllow: opts.detectorsAllow,
69
+ detectorsDeny: opts.detectorsDeny,
70
+ minConfidence: opts.minConfidence ?? 0.5,
71
+ startedAt: Date.now(),
72
+ verdictLog: [],
73
+ };
74
+ scans.set(scanId, record);
75
+ evictOldScans();
76
+ broadcast(scanId, { scanId, ts: Date.now(), state: 'queued' });
77
+ // Fire and forget — the SSE channel relays state changes.
78
+ void (async () => {
79
+ try {
80
+ await acquireScanSlot(scanId);
81
+ }
82
+ catch {
83
+ // Queue entry was dropped (cancel while queued). Record is already
84
+ // marked errored by the cancel handler; nothing else to do.
85
+ return;
86
+ }
87
+ // Cancellation that fired while we were queued — release the slot
88
+ // and skip the parse. The record is already marked errored.
89
+ if (cancelledScans.has(scanId) || record.state === 'error') {
90
+ releaseScanSlot();
91
+ return;
92
+ }
93
+ invalidateParseCache(); // fresh scan = fresh parse
94
+ // Cooperative abort signal — wired through `RotHunter.run` so the
95
+ // LLM worker pool tears itself down promptly on cancel. The old
96
+ // implementation tried to throw inside onProgress, but emit()'s
97
+ // catch swallowed the throw and the workers kept running.
98
+ const abortController = new AbortController();
99
+ if (cancelledScans.has(scanId))
100
+ abortController.abort();
101
+ abortControllers.set(scanId, abortController);
102
+ try {
103
+ const rothunter = new RotHunter();
104
+ const result = await rothunter.run({
105
+ workspaceRoot: opts.workspaceRoot,
106
+ detectorsAllow: opts.detectorsAllow ? new Set(opts.detectorsAllow) : undefined,
107
+ detectorsDeny: opts.detectorsDeny ? new Set(opts.detectorsDeny) : undefined,
108
+ llmConcurrency: opts.llmConcurrency,
109
+ llmAutoFpThreshold: opts.llmAutoFpThreshold,
110
+ abortSignal: abortController.signal,
111
+ onProgress: (event) => {
112
+ const sse = applyProgressToRecord(record, event);
113
+ broadcast(scanId, sse);
114
+ },
115
+ });
116
+ record.state = 'done';
117
+ record.finishedAt = Date.now();
118
+ const fpSet = readFalsePositives(opts.workspaceRoot);
119
+ const keptOpenSet = readKeptOpen(opts.workspaceRoot);
120
+ const split = splitFalsePositives(result.findings, fpSet, keptOpenSet);
121
+ record.findings = split.findings;
122
+ record.falsePositives = split.falsePositives;
123
+ record.symbolsCount = result.symbols.length;
124
+ record.llmStats = summarizeLlmStats(record.verdictLog);
125
+ await persistScan(record);
126
+ }
127
+ catch (err) {
128
+ record.state = 'error';
129
+ record.error = err.message;
130
+ record.finishedAt = Date.now();
131
+ broadcast(scanId, { scanId, ts: Date.now(), state: 'error', error: record.error });
132
+ logger.error({ scanId, err }, 'RotHunter scan failed');
133
+ }
134
+ finally {
135
+ releaseScanSlot();
136
+ abortControllers.delete(scanId);
137
+ // Drain SSE client set for this scan — listeners auto-prune on close
138
+ // (line ~895) but the outer Map entry survives. Force the cleanup so
139
+ // long-running servers don't accumulate empty Sets keyed by completed
140
+ // scanIds. The disconnect close handler still no-ops if the entry is
141
+ // gone (Set.delete on a removed Set just throws nothing visible).
142
+ const clients = sseClients.get(scanId);
143
+ if (clients) {
144
+ for (const res of clients) {
145
+ try {
146
+ res.end();
147
+ }
148
+ catch {
149
+ // socket may already be torn down
150
+ }
151
+ }
152
+ sseClients.delete(scanId);
153
+ }
154
+ }
155
+ })();
156
+ return scanId;
157
+ }
158
+ const app = Fastify({ logger: false });
159
+ app.get('/api/health', async () => ({
160
+ ok: true,
161
+ version: '0.1.0',
162
+ workspaceRoot: getWorkspaceRoot(),
163
+ llm: process.env.ROTHUNTER_LLM_BASE_URL ?? 'http://127.0.0.1:8080/v1',
164
+ }));
165
+ // GET /api/fs/list?path — directory listing for the folder picker.
166
+ // Defaults to $HOME. Files included for context; only dirs navigable.
167
+ app.get('/api/fs/list', async (req, reply) => {
168
+ const target = path.resolve(req.query.path?.trim() || os.homedir());
169
+ if (!isUnderAllowRoot(target)) {
170
+ return reply.code(403).send({ error: 'path outside allowed roots' });
171
+ }
172
+ if (!existsSync(target))
173
+ return reply.code(404).send({ error: 'path does not exist' });
174
+ const stat = statSync(target);
175
+ if (!stat.isDirectory())
176
+ return reply.code(400).send({ error: 'not a directory' });
177
+ let entries = [];
178
+ try {
179
+ const items = await fs.readdir(target, { withFileTypes: true });
180
+ entries = items
181
+ .map((d) => ({
182
+ name: d.name,
183
+ isDir: d.isDirectory(),
184
+ isHidden: d.name.startsWith('.'),
185
+ }))
186
+ .sort((a, b) => {
187
+ if (a.isDir !== b.isDir)
188
+ return a.isDir ? -1 : 1;
189
+ return a.name.localeCompare(b.name);
190
+ });
191
+ }
192
+ catch (err) {
193
+ return reply.code(403).send({ error: err.message });
194
+ }
195
+ const parent = path.dirname(target);
196
+ return {
197
+ path: target,
198
+ parent: parent === target ? null : parent,
199
+ entries,
200
+ };
201
+ });
202
+ app.get('/api/workspaces', async () => {
203
+ if (!existsSync(getWorkspaceRoot())) {
204
+ return { workspaces: [] };
205
+ }
206
+ const stat = statSync(getWorkspaceRoot());
207
+ // Single workspace mount (the common Docker case).
208
+ if (!stat.isDirectory())
209
+ return { workspaces: [] };
210
+ return {
211
+ workspaces: [
212
+ {
213
+ path: getWorkspaceRoot(),
214
+ name: path.basename(getWorkspaceRoot()),
215
+ },
216
+ ],
217
+ };
218
+ });
219
+ /**
220
+ * GET /api/workspace — current workspace + recent list. The UI's folder
221
+ * picker reads this on mount.
222
+ */
223
+ app.get('/api/workspace', async () => ({
224
+ current: getWorkspaceRoot(),
225
+ name: path.basename(getWorkspaceRoot()),
226
+ recent: getRecentWorkspaces(),
227
+ }));
228
+ /**
229
+ * POST /api/workspace { path } — switch the active workspace in-process,
230
+ * persist to ~/.rothunter/workspace.json, and bust the parse cache so the
231
+ * next /api/symbols/* re-parses the new tree. Validates that the path
232
+ * exists, is a directory, and is absolute.
233
+ */
234
+ app.post('/api/workspace', async (req, reply) => {
235
+ const target = req.body?.path?.trim();
236
+ if (!target)
237
+ return reply.code(400).send({ error: 'path required' });
238
+ if (!path.isAbsolute(target))
239
+ return reply.code(400).send({ error: 'absolute path required' });
240
+ if (!isUnderAllowRoot(target)) {
241
+ return reply.code(403).send({ error: 'workspace outside allowed roots' });
242
+ }
243
+ if (!existsSync(target))
244
+ return reply.code(404).send({ error: 'path does not exist' });
245
+ if (!statSync(target).isDirectory())
246
+ return reply.code(400).send({ error: 'not a directory' });
247
+ setWorkspaceRoot(target);
248
+ invalidateParseCache();
249
+ scanHistoryCache.delete(target);
250
+ await persistCurrentWorkspace();
251
+ logger.info({ workspaceRoot: target }, 'Workspace switched');
252
+ return { current: target, recent: getRecentWorkspaces() };
253
+ });
254
+ app.post('/api/scans', async (req, reply) => {
255
+ const queued = getScanQueueLength() + (getRunningScanId() ? 1 : 0);
256
+ if (queued >= SCAN_QUEUE_LIMIT) {
257
+ return reply
258
+ .code(429)
259
+ .send({ error: `scan queue full (${queued}/${SCAN_QUEUE_LIMIT}); wait for the current scan to finish` });
260
+ }
261
+ const body = req.body ?? {};
262
+ // When the caller doesn't pin detectors, derive the allow-list from
263
+ // persisted settings — only the ones the operator left toggled ON run.
264
+ const allowFromSettings = ALL_DETECTORS.filter((id) => SETTINGS.detectors[id] !== false);
265
+ const scanId = await startScan({
266
+ workspaceRoot: getWorkspaceRoot(),
267
+ detectorsAllow: body.detectors ?? allowFromSettings,
268
+ minConfidence: body.minConfidence ?? SETTINGS.minConfidence,
269
+ llmConcurrency: SETTINGS.llmConcurrency,
270
+ llmAutoFpThreshold: SETTINGS.llmAutoFpThreshold,
271
+ });
272
+ return { scanId, queuePosition: getScanQueueLength() };
273
+ });
274
+ /**
275
+ * GET /api/settings — current in-process settings. Includes the active
276
+ * LLM endpoint (read-only here; switching backends requires an env-var
277
+ * change + restart for now).
278
+ */
279
+ /**
280
+ * Detectors planned but not yet shipped. The UI shows them in the
281
+ * Settings list with a "coming soon" pill so the operator knows what is
282
+ * on the roadmap. They are not executed by the engine.
283
+ *
284
+ * Roadmap entries live in private/ROADMAP.md — don't list unshipped
285
+ * detectors here unless they are imminent. Surfacing speculative items
286
+ * on the dashboard becomes a public promise.
287
+ */
288
+ const COMING_SOON_DETECTORS = [];
289
+ function settingsPayload() {
290
+ return {
291
+ detectors: SETTINGS.detectors,
292
+ minConfidence: SETTINGS.minConfidence,
293
+ llmConcurrency: SETTINGS.llmConcurrency,
294
+ llmAutoFpThreshold: SETTINGS.llmAutoFpThreshold,
295
+ hardware: {
296
+ cpuCores: os.cpus().length,
297
+ totalMemMb: Math.round(os.totalmem() / (1024 * 1024)),
298
+ },
299
+ llm: {
300
+ baseUrl: process.env.ROTHUNTER_LLM_BASE_URL ?? 'http://127.0.0.1:8080/v1',
301
+ model: process.env.ROTHUNTER_LLM_MODEL ?? 'bartowski/Qwen2.5-Coder-14B-Instruct-GGUF',
302
+ },
303
+ allDetectors: ALL_DETECTORS,
304
+ comingSoon: COMING_SOON_DETECTORS,
305
+ };
306
+ }
307
+ app.get('/api/settings', async () => settingsPayload());
308
+ app.post('/api/settings', async (req, reply) => {
309
+ const body = req.body ?? {};
310
+ if (body.minConfidence != null) {
311
+ if (typeof body.minConfidence !== 'number' || body.minConfidence < 0 || body.minConfidence > 1) {
312
+ return reply.code(400).send({ error: 'minConfidence must be in [0, 1]' });
313
+ }
314
+ SETTINGS.minConfidence = body.minConfidence;
315
+ }
316
+ if (body.llmConcurrency != null) {
317
+ if (typeof body.llmConcurrency !== 'number' || body.llmConcurrency < 1 || body.llmConcurrency > 16) {
318
+ return reply.code(400).send({ error: 'llmConcurrency must be in [1, 16]' });
319
+ }
320
+ SETTINGS.llmConcurrency = Math.floor(body.llmConcurrency);
321
+ }
322
+ if (body.llmAutoFpThreshold != null) {
323
+ if (typeof body.llmAutoFpThreshold !== 'number' ||
324
+ body.llmAutoFpThreshold < 0 ||
325
+ body.llmAutoFpThreshold > 1) {
326
+ return reply.code(400).send({ error: 'llmAutoFpThreshold must be in [0, 1]' });
327
+ }
328
+ SETTINGS.llmAutoFpThreshold = body.llmAutoFpThreshold;
329
+ }
330
+ if (body.detectors) {
331
+ for (const [id, on] of Object.entries(body.detectors)) {
332
+ if (ALL_DETECTORS.includes(id)) {
333
+ SETTINGS.detectors[id] = !!on;
334
+ }
335
+ }
336
+ }
337
+ try {
338
+ await writeSettings(SETTINGS);
339
+ }
340
+ catch (err) {
341
+ logger.warn({ err }, 'Failed to persist settings');
342
+ }
343
+ // Return the full payload (matching GET) so the UI can replace state
344
+ // without losing `allDetectors` / `comingSoon` / `hardware` / `llm`.
345
+ return settingsPayload();
346
+ });
347
+ // POST /api/findings/:fp/prompt — copy-paste prompt for an agent (Claude
348
+ // Code / Cursor / Copilot Chat) to fix the finding via the LLM.
349
+ app.post('/api/findings/:fp/prompt', async (req, reply) => {
350
+ const fp = decodeURIComponent(req.params.fp);
351
+ // Find the finding — prefer the latest in-memory done scan, fall back to disk.
352
+ let finding;
353
+ for (const s of scans.values()) {
354
+ if (s.state !== 'done' || !s.findings)
355
+ continue;
356
+ const hit = s.findings.find((f) => f.fingerprint === fp);
357
+ if (hit) {
358
+ finding = hit;
359
+ break;
360
+ }
361
+ }
362
+ if (!finding) {
363
+ const hist = await loadScanHistory(getWorkspaceRoot());
364
+ for (const s of hist) {
365
+ const hit = s.findings?.find((f) => f.fingerprint === fp);
366
+ if (hit) {
367
+ finding = hit;
368
+ break;
369
+ }
370
+ }
371
+ }
372
+ if (!finding)
373
+ return reply.code(404).send({ error: 'finding not found' });
374
+ const evidenceBlock = finding.evidence
375
+ .map((e, i) => `Evidence ${i + 1} — \`${e.file}:${e.range.startLine}-${e.range.endLine}\`\n\`\`\`\n${e.snippet}\n\`\`\``)
376
+ .join('\n\n');
377
+ const filesToInspect = Array.from(new Set(finding.evidence.map((e) => e.file)));
378
+ const filesBlock = filesToInspect.map((f) => `- \`${f}\``).join('\n');
379
+ const system = `You are a senior TypeScript engineer drafting a prompt for ANOTHER coding agent (Claude Code, Codex, Cursor, Copilot Chat) to fix a static-analysis finding. The user will copy-paste your output verbatim into that agent. Your output must therefore be a SELF-CONTAINED instruction set, not a description.
380
+
381
+ Hard rules for the prompt you produce:
382
+
383
+ 1. Open with a one-sentence statement of the defect (severity + detector).
384
+ 2. Include a "## Files to inspect" section listing every file path verbatim as a backtick-quoted bullet list — the agent must open every one before proposing changes.
385
+ 3. Include a "## Evidence" section with each \`file:lineStart-lineEnd\` location followed by the code snippet in a fenced code block. Preserve line numbers exactly.
386
+ 4. Include a "## Required behaviour" section that summarises what the code SHOULD do after the fix, derived from the description + suggestion.
387
+ 5. Include a "## Constraints" section that explicitly forbids workarounds. Use these literal bullets (adapt wording only if a constraint genuinely doesn't apply):
388
+ - Fix the ROOT CAUSE — no try/catch swallowing, no \`@ts-ignore\` / \`any\` casts, no commented-out code.
389
+ - Do NOT add backwards-compatibility shims, feature flags, or "TODO later" stubs.
390
+ - Do NOT widen types, suppress lint rules, or skip tests to make the error go away.
391
+ - Keep the change MINIMAL — touch only what the defect requires.
392
+ - Match existing project conventions (imports, formatting, error handling).
393
+ - Add or update unit tests when the file already has a sibling test; otherwise note why tests are not added.
394
+ 6. Include a "## Suppression — if AND ONLY IF this is intentional" section. The default path is to FIX the finding. When the agent is CONFIDENT the finding is intentional design (framework idiom, deliberate pattern, detector heuristic false positive) AND not a real defect, the agent has two options:
395
+
396
+ (a) **Add a rothunter pragma directly in source** — preferred when the suppression should live with the code (signals intent to future readers + survives every rescan). Place TWO lines IMMEDIATELY above the flagged line:
397
+
398
+ \`\`\`
399
+ // rothunter:ignore-<detectorId>
400
+ // reason: <one short sentence explaining why this is intentional>
401
+ \`\`\`
402
+
403
+ Both lines required. Replace \`<detectorId>\` with the literal detector id from the finding (e.g. \`silent-catch\`, \`mutation\`, \`race-condition\`, \`magic-numbers\`, \`console-log-prod\`, \`mutable-globals\`, \`dead-export\`, \`long-function\`).
404
+
405
+ (b) **STOP and ask the operator to confirm**, including a one-paragraph rationale. The operator can then click "Mark false positive" in the dashboard if they agree — rothunter persists that decision so the finding stops surfacing on future scans.
406
+
407
+ NEVER use either path to silence a real bug. The pragma is permanent; the dashboard mark is shared with the team. When in doubt, prefer (b).
408
+
409
+ 7. End with: "Apply the fix directly to the files listed above. If the right fix would require a larger refactor than the constraints allow, STOP and explain in plain text instead of editing — do not patch around the constraints."
410
+
411
+ Output ONLY the prompt body. No preamble. No markdown fence around the whole thing. Use Markdown section headings inside the body.`;
412
+ const user = `Detector: ${finding.detectorId}
413
+ Severity: ${finding.severity}
414
+ Title: ${finding.title}
415
+
416
+ Description:
417
+ ${finding.description}
418
+
419
+ Suggested direction from RotHunter:
420
+ ${finding.suggestion ?? '(none)'}
421
+
422
+ Files to inspect (must appear verbatim in the "## Files to inspect" section of the output):
423
+ ${filesBlock}
424
+
425
+ Code evidence (must appear verbatim in the "## Evidence" section of the output):
426
+ ${evidenceBlock}
427
+
428
+ Generate the prompt now.`;
429
+ const llm = createDefaultLlmClient();
430
+ try {
431
+ const prompt = await llm.chat([
432
+ { role: 'system', content: system },
433
+ { role: 'user', content: user },
434
+ ], { temperature: 0.2, maxTokens: 1400, timeoutMs: 120_000 });
435
+ return { prompt: prompt.trim() };
436
+ }
437
+ catch (err) {
438
+ return reply.code(502).send({ error: err.message });
439
+ }
440
+ });
441
+ app.get('/api/llm/health', async (_, reply) => {
442
+ const base = process.env.ROTHUNTER_LLM_BASE_URL ?? 'http://127.0.0.1:8080/v1';
443
+ // llama.cpp exposes /health at the root, not under /v1.
444
+ const healthUrl = base.replace(/\/v1\/?$/, '') + '/health';
445
+ const started = Date.now();
446
+ try {
447
+ const r = await fetch(healthUrl, { signal: AbortSignal.timeout(2500) });
448
+ if (!r.ok)
449
+ return reply.code(502).send({ ok: false, status: r.status, latencyMs: Date.now() - started });
450
+ return { ok: true, status: r.status, latencyMs: Date.now() - started, url: healthUrl };
451
+ }
452
+ catch (err) {
453
+ return reply.code(502).send({ ok: false, error: err.message, latencyMs: Date.now() - started });
454
+ }
455
+ });
456
+ /**
457
+ * POST /api/findings/:fp/rerun — re-run the originating detector on the
458
+ * subset of files referenced by the finding, then re-run the LLM
459
+ * confirmer if the finding survives the deterministic pass. Returns:
460
+ *
461
+ * - { status: 'resolved' } — detector no longer
462
+ * reports the issue when re-run on the evidence files alone.
463
+ * - { status: 'still-present', finding } — issue still detected;
464
+ * `finding` is the refreshed object (new confidence / severity /
465
+ * verdict after the LLM pass).
466
+ *
467
+ * Cross-file detectors (duplicate-type, dead-export, dead-api,
468
+ * dead-module, similar-functions, unused-deps,
469
+ * hot-hub-file, todo-comments) are technically less accurate when run
470
+ * on a subset — they rely on whole-workspace state to declare a symbol
471
+ * unused, a type a duplicate, an import unreferenced, etc. The endpoint
472
+ * still runs them on the evidence subset and reports the result as the
473
+ * operator's intent: "did MY change fix MY finding?". The persisted
474
+ * scan record is updated either way so History / Findings views
475
+ * reflect the resolution without a manual rescan.
476
+ */
477
+ app.post('/api/findings/:fp/rerun', async (req, reply) => {
478
+ const fp = decodeURIComponent(req.params.fp);
479
+ // Locate the originating scan + finding. Walk live scans first (most
480
+ // common case: user just finished a scan, clicked into the finding,
481
+ // pasted the fix prompt into Claude Code, came back). Fall back to
482
+ // disk history if not in memory.
483
+ let owningScan;
484
+ let finding;
485
+ for (const s of scans.values()) {
486
+ if (s.state !== 'done' || !s.findings)
487
+ continue;
488
+ const hit = s.findings.find((f) => f.fingerprint === fp);
489
+ if (hit) {
490
+ owningScan = s;
491
+ finding = hit;
492
+ break;
493
+ }
494
+ }
495
+ if (!finding) {
496
+ const hist = await loadScanHistory(getWorkspaceRoot());
497
+ for (const s of hist) {
498
+ const hit = s.findings?.find((f) => f.fingerprint === fp);
499
+ if (hit) {
500
+ owningScan = s;
501
+ finding = hit;
502
+ break;
503
+ }
504
+ }
505
+ }
506
+ if (!finding || !owningScan)
507
+ return reply.code(404).send({ error: 'finding not found' });
508
+ const filesFromEvidence = Array.from(new Set(finding.evidence.map((e) => e.file)));
509
+ if (filesFromEvidence.length === 0) {
510
+ return reply.code(422).send({ status: 'unsupported', reason: 'finding has no file evidence to re-check' });
511
+ }
512
+ // Multi-workspace findings carry workspace-prefixed paths
513
+ // (e.g. `service-a/src/foo.ts`) that the single-workspace parser
514
+ // would interpret as literal paths under the monorepo root and
515
+ // fail to find. Detect the prefix shape and refuse — the rerun
516
+ // path doesn't support cross-workspace findings yet, but at
517
+ // least we surface a clear error instead of an incorrect
518
+ // "resolved" verdict.
519
+ const looksMultiWorkspace = filesFromEvidence.some((f) => {
520
+ const head = f.split('/')[0] ?? '';
521
+ if (!head)
522
+ return false;
523
+ const candidate = path.join(owningScan.workspaceRoot, head);
524
+ return !existsSync(candidate);
525
+ });
526
+ if (looksMultiWorkspace) {
527
+ return reply.code(422).send({
528
+ status: 'unsupported',
529
+ reason: 'single-finding rerun does not support multi-workspace findings yet — kick off a full scan to refresh this finding',
530
+ });
531
+ }
532
+ // Re-run just this detector against just the evidence files.
533
+ const rothunter = new RotHunter();
534
+ let result;
535
+ try {
536
+ result = await rothunter.run({
537
+ workspaceRoot: owningScan.workspaceRoot,
538
+ files: filesFromEvidence,
539
+ detectorsAllow: new Set([finding.detectorId]),
540
+ llmConcurrency: SETTINGS.llmConcurrency,
541
+ });
542
+ }
543
+ catch (err) {
544
+ return reply.code(502).send({ status: 'error', error: err.message });
545
+ }
546
+ const refreshed = result.findings.find((f) => f.fingerprint === fp);
547
+ if (!refreshed) {
548
+ // Finding gone — flip the persisted record to resolved instead of
549
+ // deleting it. Operators want a paper trail: which findings did we
550
+ // fix, when, in which scan? Deleting the entry hides that history
551
+ // and breaks the "show resolved" filter in the Findings page.
552
+ const now = Date.now();
553
+ if (owningScan.findings) {
554
+ owningScan.findings = owningScan.findings.map((f) => f.fingerprint === fp ? { ...f, resolvedAt: now } : f);
555
+ }
556
+ try {
557
+ await persistScan(owningScan);
558
+ }
559
+ catch (err) {
560
+ logger.warn({ err, scanId: owningScan.scanId }, 'rerun: failed to persist resolved finding');
561
+ }
562
+ return { status: 'resolved', resolvedAt: now };
563
+ }
564
+ // Still present — update in-place. Same fingerprint, possibly new
565
+ // confidence / severity / description from the re-issued LLM verdict.
566
+ if (owningScan.findings) {
567
+ owningScan.findings = owningScan.findings.map((f) => (f.fingerprint === fp ? refreshed : f));
568
+ }
569
+ try {
570
+ await persistScan(owningScan);
571
+ }
572
+ catch (err) {
573
+ logger.warn({ err, scanId: owningScan.scanId }, 'rerun: failed to persist refreshed finding');
574
+ }
575
+ return { status: 'still-present', finding: refreshed };
576
+ });
577
+ app.get('/api/scans', async () => {
578
+ const ws = getWorkspaceRoot();
579
+ const history = await loadScanHistory(ws);
580
+ // Live scans (still in-memory) merged in front. Filter to the active
581
+ // workspace — without this, scans launched against a different
582
+ // workspace bleed into the current listing after a switch and
583
+ // confuse the dashboard / live banner.
584
+ const live = [...scans.values()].filter((s) => s.workspaceRoot === ws && s.state !== 'done' && s.state !== 'error');
585
+ // Disk-loaded history is partitioned at scan-completion time. Re-apply
586
+ // the current FP + kept-open overrides so unmark FP clicks made AFTER
587
+ // a scan stay visible across page reloads.
588
+ const repartitionedHistory = history.slice(0, 50).map((s) => repartitionScanRecord(s, ws));
589
+ return { scans: [...live, ...repartitionedHistory] };
590
+ });
591
+ app.get('/api/scans/:scanId', async (req, reply) => {
592
+ if (!SCAN_ID_RE.test(req.params.scanId)) {
593
+ return reply.code(400).send({ error: 'invalid scan id' });
594
+ }
595
+ const ws = getWorkspaceRoot();
596
+ const live = scans.get(req.params.scanId);
597
+ // Workspace-scope the lookup. The in-memory `scans` Map is global,
598
+ // so without this guard a scan started against workspace A would
599
+ // still be reachable by id from workspace B (the picker reload
600
+ // doesn't kill the server-side state). The disk path is already
601
+ // scoped because `dir` is built from getWorkspaceRoot().
602
+ if (live && live.workspaceRoot === ws)
603
+ return live;
604
+ const dir = path.join(ws, '.rothunter', 'scans');
605
+ const file = path.join(dir, `${req.params.scanId}.json`);
606
+ if (!existsSync(file)) {
607
+ return reply.code(404).send({ error: 'scan not found' });
608
+ }
609
+ const record = JSON.parse(await fs.readFile(file, 'utf-8'));
610
+ // Re-apply the partition against the CURRENT FP + kept-open stores.
611
+ // The persisted JSON only reflects the split at scan-completion time;
612
+ // mark / unmark FP after the fact must be visible on a page reload
613
+ // even though the disk copy is stale. The persisted file stays
614
+ // immutable — repartition is a read-time concern.
615
+ return repartitionScanRecord(record, ws);
616
+ });
617
+ /**
618
+ * Apply the workspace's current FP + kept-open overrides to a scan
619
+ * record loaded from disk. Pure — never writes back, so the operator's
620
+ * "snapshot at scan time" record stays auditable while the displayed
621
+ * partition follows the latest user / LLM decisions.
622
+ */
623
+ function repartitionScanRecord(record, ws) {
624
+ if (!record.findings && !record.falsePositives)
625
+ return record;
626
+ const fpSet = readFalsePositives(ws);
627
+ const keptOpen = readKeptOpen(ws);
628
+ const all = [...(record.findings ?? []), ...(record.falsePositives ?? [])];
629
+ const split = splitFalsePositives(all, fpSet, keptOpen);
630
+ return { ...record, findings: split.findings, falsePositives: split.falsePositives };
631
+ }
632
+ /**
633
+ * GET /api/scans/:scanId/llm-stats — aggregate LLM telemetry.
634
+ *
635
+ * Computed once at scan-finish and persisted on `ScanRecord.llmStats`.
636
+ * For historical scans (persisted before this endpoint shipped), the
637
+ * stats are recomputed on the fly from `verdictLog` so the History view
638
+ * shows numbers without a manual migration. Mid-flight scans return
639
+ * stats for the verdicts seen so far — useful to spot a wedged backend
640
+ * (p95 climbing scan-over-scan).
641
+ */
642
+ app.get('/api/scans/:scanId/llm-stats', async (req, reply) => {
643
+ if (!SCAN_ID_RE.test(req.params.scanId)) {
644
+ return reply.code(400).send({ error: 'invalid scan id' });
645
+ }
646
+ let record = scans.get(req.params.scanId);
647
+ if (!record) {
648
+ const file = path.join(getWorkspaceRoot(), '.rothunter', 'scans', `${req.params.scanId}.json`);
649
+ if (!existsSync(file))
650
+ return reply.code(404).send({ error: 'scan not found' });
651
+ try {
652
+ record = JSON.parse(await fs.readFile(file, 'utf-8'));
653
+ }
654
+ catch {
655
+ return reply.code(500).send({ error: 'scan record unreadable' });
656
+ }
657
+ }
658
+ const stats = record.llmStats ?? summarizeLlmStats(record.verdictLog ?? []);
659
+ return { scanId: record.scanId, state: record.state, stats };
660
+ });
661
+ // GET /api/scans/:scanId/diff?vs=<id> — { base, added, removed, persisting }
662
+ // by fingerprint equality. `vs` defaults to previous scan with findings.
663
+ app.get('/api/scans/:scanId/diff', async (req, reply) => {
664
+ if (!SCAN_ID_RE.test(req.params.scanId)) {
665
+ return reply.code(400).send({ error: 'invalid scan id' });
666
+ }
667
+ if (req.query.vs && !SCAN_ID_RE.test(req.query.vs)) {
668
+ return reply.code(400).send({ error: 'invalid vs scan id' });
669
+ }
670
+ const live = scans.get(req.params.scanId);
671
+ let current = live;
672
+ if (!current) {
673
+ const dir = path.join(getWorkspaceRoot(), '.rothunter', 'scans');
674
+ const file = path.join(dir, `${req.params.scanId}.json`);
675
+ if (existsSync(file))
676
+ current = JSON.parse(await fs.readFile(file, 'utf-8'));
677
+ }
678
+ if (!current)
679
+ return reply.code(404).send({ error: 'scan not found' });
680
+ if (!current.findings)
681
+ return reply.code(409).send({ error: 'scan still in flight' });
682
+ const history = await loadScanHistory(getWorkspaceRoot());
683
+ let base = null;
684
+ if (req.query.vs) {
685
+ base = history.find((s) => s.scanId === req.query.vs) ?? null;
686
+ }
687
+ else {
688
+ // Walk past the current scan, pick the next-newest with findings.
689
+ const idx = history.findIndex((s) => s.scanId === req.params.scanId);
690
+ for (let i = idx + 1; i < history.length; i++) {
691
+ if (history[i].findings) {
692
+ base = history[i];
693
+ break;
694
+ }
695
+ }
696
+ }
697
+ if (!base || !base.findings) {
698
+ return {
699
+ base: null,
700
+ added: current.findings,
701
+ removed: [],
702
+ persisting: [],
703
+ };
704
+ }
705
+ const currentFp = new Set(current.findings.map((f) => f.fingerprint));
706
+ const baseFp = new Set(base.findings.map((f) => f.fingerprint));
707
+ return {
708
+ base: base.scanId,
709
+ added: current.findings.filter((f) => !baseFp.has(f.fingerprint)),
710
+ removed: base.findings.filter((f) => !currentFp.has(f.fingerprint)),
711
+ persisting: current.findings.filter((f) => baseFp.has(f.fingerprint)),
712
+ };
713
+ });
714
+ app.get('/api/scans/:scanId/stream', (req, reply) => {
715
+ const { scanId } = req.params;
716
+ if (!SCAN_ID_RE.test(scanId)) {
717
+ return reply.code(400).send({ error: 'invalid scan id' });
718
+ }
719
+ // Refuse to stream a scan that belongs to a different workspace.
720
+ // Without this the LiveScanBanner on workspace B could subscribe to
721
+ // a scan still running against workspace A (the scanId is reachable
722
+ // from anywhere) and the operator would see ghost progress events.
723
+ const ws = getWorkspaceRoot();
724
+ const current = scans.get(scanId);
725
+ if (current && current.workspaceRoot !== ws) {
726
+ return reply.code(404).send({ error: 'scan belongs to a different workspace' });
727
+ }
728
+ reply.raw.setHeader('Content-Type', 'text/event-stream');
729
+ reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
730
+ reply.raw.setHeader('Connection', 'keep-alive');
731
+ reply.raw.flushHeaders();
732
+ const set = sseClients.get(scanId) ?? new Set();
733
+ set.add(reply.raw);
734
+ sseClients.set(scanId, set);
735
+ // Replay accumulated scan state so a late subscriber (reload mid-scan)
736
+ // sees the full pipeline without having to query the snapshot endpoint
737
+ // separately. We replay:
738
+ // 1. A synthetic parsing event carrying files/symbols counts.
739
+ // 2. One detecting event per completed detector, then the active one.
740
+ // 3. A snapshot of LLM progress (done / total).
741
+ // 4. The verdict log so the verdict-stream panel repopulates.
742
+ // 5. The latest progress event (state machine).
743
+ if (current) {
744
+ if (current.filesCount != null || current.symbolsCount != null) {
745
+ reply.raw.write(`data: ${JSON.stringify({
746
+ scanId,
747
+ ts: Date.now(),
748
+ state: 'parsing',
749
+ files: current.filesCount,
750
+ symbols: current.symbolsCount,
751
+ })}\n\n`);
752
+ }
753
+ for (const det of current.doneDetectors ?? []) {
754
+ reply.raw.write(`data: ${JSON.stringify({ scanId, ts: Date.now(), state: 'detecting', detector: det })}\n\n`);
755
+ }
756
+ if (current.activeDetector) {
757
+ reply.raw.write(`data: ${JSON.stringify({
758
+ scanId,
759
+ ts: Date.now(),
760
+ state: 'detecting',
761
+ detector: current.activeDetector,
762
+ })}\n\n`);
763
+ }
764
+ if (current.llmTotal != null) {
765
+ reply.raw.write(`data: ${JSON.stringify({
766
+ scanId,
767
+ ts: Date.now(),
768
+ state: 'llm-start',
769
+ llmTotal: current.llmTotal,
770
+ })}\n\n`);
771
+ }
772
+ for (const v of current.verdictLog) {
773
+ const replay = {
774
+ scanId,
775
+ ts: Date.now(),
776
+ state: 'llm-verdict',
777
+ verdict: v,
778
+ };
779
+ reply.raw.write(`data: ${JSON.stringify(replay)}\n\n`);
780
+ }
781
+ // Final state snapshot last so the UI's state machine lands on the
782
+ // current phase after consuming all the historical events above.
783
+ const snapshot = current.progress ?? { scanId, ts: Date.now(), state: current.state };
784
+ reply.raw.write(`data: ${JSON.stringify(snapshot)}\n\n`);
785
+ }
786
+ req.raw.on('close', () => {
787
+ set.delete(reply.raw);
788
+ });
789
+ });
790
+ app.get('/api/scans/series', async (req) => {
791
+ const win = req.query.window ?? '30d';
792
+ const days = /^(\d+)d$/.test(win) ? Number(win.slice(0, -1)) : 30;
793
+ const cutoff = Date.now() - days * 86400_000;
794
+ const history = await loadScanHistory(getWorkspaceRoot());
795
+ const entries = history
796
+ .filter((s) => s.startedAt >= cutoff)
797
+ .map((s) => {
798
+ const counts = { high: 0, med: 0, low: 0 };
799
+ for (const f of s.findings ?? []) {
800
+ if (f.severity === 'high')
801
+ counts.high += 1;
802
+ else if (f.severity === 'medium')
803
+ counts.med += 1;
804
+ else
805
+ counts.low += 1;
806
+ }
807
+ // Prefer persisted llmStats; fall back to a lazy recompute from
808
+ // verdictLog so old scans (persisted before llmStats shipped) still
809
+ // surface latency in the History view.
810
+ const stats = s.llmStats ?? (s.verdictLog?.length ? summarizeLlmStats(s.verdictLog) : null);
811
+ return {
812
+ scanId: s.scanId,
813
+ startedAt: s.startedAt,
814
+ finishedAt: s.finishedAt ?? null,
815
+ durationMs: s.finishedAt ? s.finishedAt - s.startedAt : null,
816
+ high: counts.high,
817
+ med: counts.med,
818
+ low: counts.low,
819
+ total: counts.high + counts.med + counts.low,
820
+ note: null,
821
+ llmCalls: stats?.calls ?? null,
822
+ llmP50Ms: stats?.p50LatencyMs ?? null,
823
+ llmP95Ms: stats?.p95LatencyMs ?? null,
824
+ };
825
+ });
826
+ // Stats for the KPI strip.
827
+ const current = entries[0]?.high ?? 0;
828
+ const oldest = entries[entries.length - 1]?.high ?? 0;
829
+ const change = current - oldest;
830
+ const avgDuration = entries.length === 0
831
+ ? null
832
+ : Math.round(entries
833
+ .map((e) => e.durationMs ?? 0)
834
+ .reduce((a, b) => a + b, 0) / entries.length);
835
+ // Average verdict latency across the window — only counts scans that
836
+ // emitted at least one verdict so empty fast scans don't pull the mean
837
+ // toward zero.
838
+ const withLlm = entries.filter((e) => e.llmP50Ms != null && e.llmCalls && e.llmCalls > 0);
839
+ const avgVerdictMs = withLlm.length === 0
840
+ ? null
841
+ : Math.round(withLlm.reduce((s, e) => s + (e.llmP50Ms ?? 0), 0) / withLlm.length);
842
+ const avgP95Ms = withLlm.length === 0
843
+ ? null
844
+ : Math.round(withLlm.reduce((s, e) => s + (e.llmP95Ms ?? 0), 0) / withLlm.length);
845
+ return {
846
+ window: win,
847
+ entries,
848
+ summary: {
849
+ count: entries.length,
850
+ currentHigh: current,
851
+ change30d: change,
852
+ avgDurationMs: avgDuration,
853
+ avgVerdictMs,
854
+ avgP95Ms,
855
+ },
856
+ };
857
+ });
858
+ app.post('/api/scans/:scanId/cancel', async (req, reply) => {
859
+ const { scanId } = req.params;
860
+ if (!SCAN_ID_RE.test(scanId)) {
861
+ return reply.code(400).send({ error: 'invalid scan id' });
862
+ }
863
+ const record = scans.get(scanId);
864
+ if (!record)
865
+ return reply.code(404).send({ error: 'scan not found' });
866
+ if (record.state === 'done' || record.state === 'error') {
867
+ return { ok: true, already: record.state };
868
+ }
869
+ cancelledScans.add(scanId);
870
+ // Fire the abort signal — the LLM worker pool inside RotHunter checks
871
+ // it between verdicts and bails out, freeing the scan slot promptly
872
+ // so the queued next scan can run.
873
+ abortControllers.get(scanId)?.abort();
874
+ record.state = 'error';
875
+ record.error = 'cancelled by user';
876
+ record.finishedAt = Date.now();
877
+ broadcast(scanId, { scanId, ts: Date.now(), state: 'error', error: 'cancelled by user' });
878
+ // If still queued, drop the starter so the slot promotes immediately.
879
+ // (The acquireScanSlot promise inside the scan flow stays pending; the
880
+ // outer flow short-circuits on the cancel flag before it ever runs.)
881
+ dropQueuedScan(scanId);
882
+ return { ok: true };
883
+ });
884
+ /**
885
+ * DELETE /api/scans/:scanId — remove a finished scan's persisted record
886
+ * + drop it from the in-memory cache. Refuses to delete a live scan;
887
+ * cancel it first. Leaves `.rothunterignore` untouched.
888
+ */
889
+ app.delete('/api/scans/:scanId', async (req, reply) => {
890
+ const { scanId } = req.params;
891
+ // Strict allow-list for the scan id shape (`scan_<base36-ts>_<rand>`).
892
+ // `req.params.scanId` lands directly in a `path.join` below — without
893
+ // validation a request like `../../../etc/passwd` would unlink an
894
+ // arbitrary file the process can write.
895
+ if (!SCAN_ID_RE.test(scanId)) {
896
+ return reply.code(400).send({ error: 'invalid scan id' });
897
+ }
898
+ const live = scans.get(scanId);
899
+ if (live && live.state !== 'done' && live.state !== 'error') {
900
+ return reply.code(409).send({ error: 'scan still running — cancel first' });
901
+ }
902
+ scans.delete(scanId);
903
+ sseClients.delete(scanId);
904
+ const file = path.join(getWorkspaceRoot(), '.rothunter', 'scans', `${scanId}.json`);
905
+ if (existsSync(file)) {
906
+ await fs.unlink(file);
907
+ }
908
+ scanHistoryCache.delete(getWorkspaceRoot());
909
+ return { ok: true };
910
+ });
911
+ /**
912
+ * Stable scan-id shape emitted by `startScan`: `scan_<base36-ts>_<rand>`.
913
+ * Used as a strict allow-list anywhere `req.params.scanId` reaches the
914
+ * filesystem, to keep path traversal off the table.
915
+ */
916
+ const SCAN_ID_RE = /^scan_[a-z0-9]+_[a-z0-9]+$/i;
917
+ /**
918
+ * POST /api/findings/:fp/false-positive — mark a finding as a false
919
+ * positive. The fingerprint is persisted to
920
+ * `<workspace>/.rothunter/false-positives.json` and applied on every
921
+ * future scan — the finding still surfaces, but in the dedicated FP
922
+ * section, not in the main bug list.
923
+ *
924
+ * DELETE clears the flag (the finding re-enters the normal list on the
925
+ * next scan).
926
+ */
927
+ app.post('/api/findings/:fp/false-positive', async (req) => {
928
+ const fp = decodeURIComponent(req.params.fp);
929
+ // Mark FP: add to FP store AND remove from kept-open store (user is
930
+ // now saying "yes, this IS an FP" — overrides any prior un-FP they
931
+ // may have set).
932
+ await mutateKeptOpen((s) => s.delete(fp));
933
+ return mutateFalsePositives((s) => s.add(fp));
934
+ });
935
+ app.delete('/api/findings/:fp/false-positive', async (req) => {
936
+ const fp = decodeURIComponent(req.params.fp);
937
+ // Unmark FP: remove from FP store AND record the explicit "keep open"
938
+ // override so the LLM auto-FP path (per-scan) cannot route this back
939
+ // into the FP bucket. Without the second step a user-unmarked finding
940
+ // bounces straight back to FP on the next scan when the LLM re-runs.
941
+ await mutateFalsePositives((s) => s.delete(fp));
942
+ return mutateKeptOpen((s) => s.add(fp));
943
+ });
944
+ /**
945
+ * Batch mark / unmark as false-positive. Same shape + same race
946
+ * mitigation as `/api/marked-to-fix/batch` — N parallel POSTs would
947
+ * stomp each other's JSON file write; one batched request + one
948
+ * critical section keeps the store consistent.
949
+ *
950
+ * Add ⇒ FP store gets the fingerprint, kept-open store loses it.
951
+ * Remove ⇒ FP store loses the fingerprint, kept-open store gets it.
952
+ * That two-store dance is what makes the UI's Unmark button stick.
953
+ */
954
+ app.post('/api/false-positives/batch', async (req) => {
955
+ const body = req.body ?? {};
956
+ const add = body.add ?? [];
957
+ const remove = body.remove ?? [];
958
+ await mutateKeptOpen((s) => {
959
+ for (const fp of add)
960
+ s.delete(fp);
961
+ for (const fp of remove)
962
+ s.add(fp);
963
+ });
964
+ return mutateFalsePositives((s) => {
965
+ for (const fp of add)
966
+ s.add(fp);
967
+ for (const fp of remove)
968
+ s.delete(fp);
969
+ });
970
+ });
971
+ app.get('/api/false-positives', async () => {
972
+ const set = readFalsePositives(getWorkspaceRoot());
973
+ return { fingerprints: [...set].sort() };
974
+ });
975
+ /**
976
+ * Serialise read-modify-write on the false-positive store. The
977
+ * promise chain queues every incoming request behind the previous —
978
+ * Node is single-threaded so the chain itself is race-free; the queue
979
+ * just prevents one request's read from interleaving with another
980
+ * request's write. Mirrors `mutateMarkedToFix`.
981
+ *
982
+ * The retroactive in-memory split keeps every running scan record
983
+ * partitioned correctly so the Findings UI updates without a rescan.
984
+ */
985
+ let falsePositiveMutation = Promise.resolve();
986
+ async function mutateFalsePositives(mutate) {
987
+ const next = falsePositiveMutation.then(async () => {
988
+ const ws = getWorkspaceRoot();
989
+ const set = readFalsePositives(ws);
990
+ mutate(set);
991
+ await writeFalsePositives(ws, set);
992
+ await reapplySplitToScans(ws);
993
+ return { ok: true, count: set.size };
994
+ });
995
+ falsePositiveMutation = next.catch(() => undefined);
996
+ return next;
997
+ }
998
+ /**
999
+ * Mirror of `mutateFalsePositives` for the kept-open override store.
1000
+ * Same serialisation pattern + same retroactive split so the UI sees
1001
+ * the unmark immediately on every in-memory scan record.
1002
+ */
1003
+ let keptOpenMutation = Promise.resolve();
1004
+ async function mutateKeptOpen(mutate) {
1005
+ const next = keptOpenMutation.then(async () => {
1006
+ const ws = getWorkspaceRoot();
1007
+ const set = readKeptOpen(ws);
1008
+ mutate(set);
1009
+ await writeKeptOpen(ws, set);
1010
+ await reapplySplitToScans(ws);
1011
+ return { ok: true, count: set.size };
1012
+ });
1013
+ keptOpenMutation = next.catch(() => undefined);
1014
+ return next;
1015
+ }
1016
+ /**
1017
+ * Re-split every in-memory scan against the CURRENT FP + kept-open
1018
+ * stores. Called by both mutators so a single click triggers exactly
1019
+ * one re-partition — the two mutators chain through their own queues
1020
+ * but converge on this helper.
1021
+ */
1022
+ async function reapplySplitToScans(ws) {
1023
+ const fpSet = readFalsePositives(ws);
1024
+ const keptOpen = readKeptOpen(ws);
1025
+ for (const s of scans.values()) {
1026
+ if (!s.findings && !s.falsePositives)
1027
+ continue;
1028
+ const all = [...(s.findings ?? []), ...(s.falsePositives ?? [])];
1029
+ const split = splitFalsePositives(all, fpSet, keptOpen);
1030
+ s.findings = split.findings;
1031
+ s.falsePositives = split.falsePositives;
1032
+ }
1033
+ }
1034
+ /**
1035
+ * Marked-to-fix queue. Operator picks findings from the detail page;
1036
+ * the dashboard can then ask the LLM to compose a single combined
1037
+ * prompt for the whole queue — useful for fixing a batch in one
1038
+ * paste into Claude Code / Cursor instead of repeating the
1039
+ * generate-prompt flow per finding.
1040
+ *
1041
+ * POST /api/findings/:fp/mark-to-fix — add fingerprint
1042
+ * DELETE /api/findings/:fp/mark-to-fix — remove fingerprint
1043
+ * GET /api/marked-to-fix — list fingerprints + matching findings
1044
+ * POST /api/marked-to-fix/prompt — deterministically-built combined prompt
1045
+ */
1046
+ app.post('/api/findings/:fp/mark-to-fix', async (req) => {
1047
+ const fp = decodeURIComponent(req.params.fp);
1048
+ return mutateMarkedToFix((s) => s.add(fp));
1049
+ });
1050
+ app.delete('/api/findings/:fp/mark-to-fix', async (req) => {
1051
+ const fp = decodeURIComponent(req.params.fp);
1052
+ return mutateMarkedToFix((s) => s.delete(fp));
1053
+ });
1054
+ /**
1055
+ * Batch mark / unmark. Bulk-select on the Findings page previously
1056
+ * fired N parallel POSTs, each doing a read-modify-write on the same
1057
+ * JSON file. Concurrent writes raced + only the last write survived
1058
+ * (operator marked 88 findings, file ended with 11). This endpoint
1059
+ * mutates the set in one critical section.
1060
+ */
1061
+ app.post('/api/marked-to-fix/batch', async (req) => {
1062
+ const body = req.body ?? {};
1063
+ return mutateMarkedToFix((s) => {
1064
+ for (const fp of body.add ?? [])
1065
+ s.add(fp);
1066
+ for (const fp of body.remove ?? [])
1067
+ s.delete(fp);
1068
+ });
1069
+ });
1070
+ // Serialise read-modify-write on the marked-to-fix store. The
1071
+ // `mutationQueue` chains every incoming request behind the previous
1072
+ // one — Node is single-threaded so the chain itself is race-free; the
1073
+ // queue just keeps an in-flight write from being interleaved with the
1074
+ // next request's read.
1075
+ let markedToFixMutation = Promise.resolve();
1076
+ async function mutateMarkedToFix(mutate) {
1077
+ const next = markedToFixMutation.then(async () => {
1078
+ const ws = getWorkspaceRoot();
1079
+ const set = readMarkedToFix(ws);
1080
+ mutate(set);
1081
+ await writeMarkedToFix(ws, set);
1082
+ return { ok: true, count: set.size };
1083
+ });
1084
+ markedToFixMutation = next.catch(() => undefined);
1085
+ return next;
1086
+ }
1087
+ app.get('/api/marked-to-fix', async () => {
1088
+ const ws = getWorkspaceRoot();
1089
+ const set = readMarkedToFix(ws);
1090
+ // Resolve fingerprints to findings via live scans + disk history so
1091
+ // the dashboard can render titles + file paths without round-tripping
1092
+ // per-finding through `/api/findings/:fp`.
1093
+ const seen = new Map();
1094
+ for (const s of scans.values()) {
1095
+ if (s.workspaceRoot !== ws || !s.findings)
1096
+ continue;
1097
+ for (const f of s.findings)
1098
+ if (set.has(f.fingerprint) && !seen.has(f.fingerprint))
1099
+ seen.set(f.fingerprint, f);
1100
+ }
1101
+ if (seen.size < set.size) {
1102
+ const hist = await loadScanHistory(ws);
1103
+ for (const s of hist) {
1104
+ for (const f of s.findings ?? []) {
1105
+ if (set.has(f.fingerprint) && !seen.has(f.fingerprint))
1106
+ seen.set(f.fingerprint, f);
1107
+ }
1108
+ if (seen.size >= set.size)
1109
+ break;
1110
+ }
1111
+ }
1112
+ return {
1113
+ fingerprints: [...set].sort(),
1114
+ findings: [...seen.values()],
1115
+ };
1116
+ });
1117
+ app.post('/api/marked-to-fix/prompt', async (_req, reply) => {
1118
+ const ws = getWorkspaceRoot();
1119
+ const set = readMarkedToFix(ws);
1120
+ if (set.size === 0) {
1121
+ return reply.code(400).send({ error: 'no findings marked to fix' });
1122
+ }
1123
+ // Same finding-resolution dance as the GET endpoint — pull live first
1124
+ // then disk history.
1125
+ const findings = [];
1126
+ const seenFp = new Set();
1127
+ for (const s of scans.values()) {
1128
+ if (s.workspaceRoot !== ws || !s.findings)
1129
+ continue;
1130
+ for (const f of s.findings) {
1131
+ if (set.has(f.fingerprint) && !seenFp.has(f.fingerprint)) {
1132
+ findings.push(f);
1133
+ seenFp.add(f.fingerprint);
1134
+ }
1135
+ }
1136
+ }
1137
+ if (findings.length < set.size) {
1138
+ const hist = await loadScanHistory(ws);
1139
+ for (const s of hist) {
1140
+ for (const f of s.findings ?? []) {
1141
+ if (set.has(f.fingerprint) && !seenFp.has(f.fingerprint)) {
1142
+ findings.push(f);
1143
+ seenFp.add(f.fingerprint);
1144
+ }
1145
+ }
1146
+ if (findings.length >= set.size)
1147
+ break;
1148
+ }
1149
+ }
1150
+ if (findings.length === 0) {
1151
+ return reply.code(404).send({ error: 'marked fingerprints have no matching findings on record' });
1152
+ }
1153
+ const prompt = renderCombinedFixPrompt(findings);
1154
+ return { prompt, findingCount: findings.length };
1155
+ });
1156
+ /**
1157
+ * Build the combined fix prompt deterministically — no LLM call.
1158
+ *
1159
+ * Why no LLM: every section that was previously asked of the model
1160
+ * was either fixed boilerplate (#1, #4–#7) or a direct render of the
1161
+ * finding data (#2, #3). The LLM was contributing zero synthesis and
1162
+ * routinely overflowed the 8 K context window (50+ findings produced
1163
+ * 18 K-token prompts). Rendering in JS is faster, deterministic, and
1164
+ * has no token budget.
1165
+ */
1166
+ function renderCombinedFixPrompt(findings) {
1167
+ const sevCount = { high: 0, medium: 0, low: 0 };
1168
+ for (const f of findings)
1169
+ sevCount[f.severity] = (sevCount[f.severity] ?? 0) + 1;
1170
+ const sevMix = ['high', 'medium', 'low']
1171
+ .filter((s) => (sevCount[s] ?? 0) > 0)
1172
+ .map((s) => `${sevCount[s]} ${s}`)
1173
+ .join(', ');
1174
+ const uniqueFiles = Array.from(new Set(findings.flatMap((f) => f.evidence.map((e) => e.file)))).sort();
1175
+ const filesBullets = uniqueFiles.map((p) => `- \`${p}\``).join('\n');
1176
+ const findingBlocks = findings
1177
+ .map((f, i) => {
1178
+ const primary = f.evidence[0];
1179
+ const loc = primary
1180
+ ? `\`${primary.file}:${primary.range.startLine}-${primary.range.endLine}\``
1181
+ : '(no evidence)';
1182
+ const evidence = f.evidence
1183
+ .map((e) => `\`${e.file}:${e.range.startLine}-${e.range.endLine}\`\n\`\`\`\n${e.snippet}\n\`\`\``)
1184
+ .join('\n\n');
1185
+ return `### ${i + 1}. ${f.detectorId} · ${f.severity} · ${loc}
1186
+ **Title:** ${f.title}
1187
+
1188
+ ${f.description}
1189
+
1190
+ **Suggested direction:** ${f.suggestion ?? '(none)'}
1191
+
1192
+ ${evidence}`;
1193
+ })
1194
+ .join('\n\n');
1195
+ return `Fix the following ${findings.length} static-analysis finding${findings.length === 1 ? '' : 's'} surfaced by rothunter (${sevMix}).
1196
+
1197
+ ## Files to inspect
1198
+ ${filesBullets}
1199
+
1200
+ ## Findings
1201
+ ${findingBlocks}
1202
+
1203
+ ## Required behaviour
1204
+ Each fix must remove the root cause of its finding while preserving the file's existing public contract and tests. The end state: every finding above no longer reproduces, the surrounding code still reads idiomatically for this project, and no new lint / type errors appear.
1205
+
1206
+ ## Constraints
1207
+ - Fix the ROOT CAUSE for each finding — no try/catch swallowing, no \`@ts-ignore\` / \`any\` casts, no commented-out code.
1208
+ - Do NOT add backwards-compatibility shims, feature flags, or "TODO later" stubs.
1209
+ - Do NOT widen types, suppress lint rules, or skip tests to make errors disappear.
1210
+ - Keep each change MINIMAL — touch only what its defect requires.
1211
+ - Match existing project conventions (imports, formatting, error handling).
1212
+ - Add or update unit tests when the file already has a sibling test.
1213
+
1214
+ ## Suppression — if AND ONLY IF intentional, per-finding
1215
+ Default path is to FIX. For any finding you are CONFIDENT is intentional design (framework idiom, deliberate pattern, detector heuristic false positive) AND not a real defect, you have two options:
1216
+
1217
+ **(a) Add a rothunter pragma directly in source** — preferred when the suppression should live with the code (signals intent to future readers + survives every rescan). Place TWO lines IMMEDIATELY above the flagged line:
1218
+
1219
+ \`\`\`
1220
+ // rothunter:ignore-<detectorId>
1221
+ // reason: <one short sentence explaining why this is intentional>
1222
+ \`\`\`
1223
+
1224
+ Both lines required. Replace \`<detectorId>\` with the literal detector id from the finding (e.g. \`silent-catch\`, \`mutation\`, \`race-condition\`, \`magic-numbers\`, \`console-log-prod\`, \`mutable-globals\`, \`dead-export\`, \`long-function\`).
1225
+
1226
+ **(b) STOP at that finding and ask the operator to confirm**, with a one-paragraph rationale. The operator can then click "Mark false positive" in the dashboard if they agree — rothunter persists that decision so the finding stops surfacing on future scans.
1227
+
1228
+ NEVER use either path to silence a real bug. The pragma is permanent; the dashboard mark is shared with the team. When in doubt, prefer (b).
1229
+
1230
+ Apply the fixes directly to the files listed above. Work through the findings in the order given. If any fix would require a larger refactor than the constraints allow, STOP at that finding and explain in plain text instead of editing.`;
1231
+ }
1232
+ // GET /api/code-window?file&line&end?&context? — CodeWindow with `context`
1233
+ // lines of padding for non-primary evidence locations.
1234
+ app.get('/api/code-window', async (req, reply) => {
1235
+ const { file, line, end, context } = req.query;
1236
+ if (!file || !line) {
1237
+ return reply.code(400).send({ error: 'file and line query params are required' });
1238
+ }
1239
+ const startLine = Number(line);
1240
+ const endLine = end ? Number(end) : startLine;
1241
+ const ctx = Math.max(0, Math.min(60, Number(context ?? 6)));
1242
+ // Guard against path traversal — the resolved path must stay inside
1243
+ // the mounted workspace.
1244
+ const resolved = path.resolve(getWorkspaceRoot(), file);
1245
+ if (!resolved.startsWith(path.resolve(getWorkspaceRoot()) + path.sep)) {
1246
+ return reply.code(400).send({ error: 'file is outside the workspace' });
1247
+ }
1248
+ if (!existsSync(resolved)) {
1249
+ return reply.code(404).send({ error: 'file not found' });
1250
+ }
1251
+ const fullText = await fs.readFile(resolved, 'utf-8');
1252
+ const lines = fullText.split(/\r?\n/);
1253
+ const start = Math.max(1, startLine - ctx);
1254
+ const stop = Math.min(lines.length, endLine + ctx);
1255
+ return {
1256
+ file,
1257
+ startLine: start,
1258
+ endLine: stop,
1259
+ highlightFrom: startLine,
1260
+ highlightTo: endLine,
1261
+ lines: lines.slice(start - 1, stop),
1262
+ };
1263
+ });
1264
+ app.get('/api/findings/:fp', async (req, reply) => {
1265
+ const fp = decodeURIComponent(req.params.fp);
1266
+ const contextLines = Math.max(0, Math.min(60, Number(req.query.context ?? 6)));
1267
+ let finding;
1268
+ // Look in latest live scan first.
1269
+ for (const scan of scans.values()) {
1270
+ finding = scan.findings?.find((f) => f.fingerprint === fp);
1271
+ if (finding)
1272
+ break;
1273
+ }
1274
+ if (!finding) {
1275
+ // Fall back to most-recent persisted scan.
1276
+ const history = await loadScanHistory(getWorkspaceRoot());
1277
+ for (const r of history) {
1278
+ if (!r.findings)
1279
+ continue;
1280
+ finding = r.findings.find((f) => f.fingerprint === fp);
1281
+ if (finding)
1282
+ break;
1283
+ }
1284
+ }
1285
+ if (!finding)
1286
+ return reply.code(404).send({ error: 'finding not found' });
1287
+ const evidence = finding.evidence?.[0];
1288
+ if (!evidence)
1289
+ return { finding, codeWindow: null };
1290
+ // Evidence paths come from a detector run against this workspace,
1291
+ // but the scan record is read off disk and could be tampered with
1292
+ // (or, in multi-workspace mode, point at a sibling repo). Resolve
1293
+ // against the workspace root and refuse anything that escapes.
1294
+ const ws = path.resolve(getWorkspaceRoot());
1295
+ const filePath = path.resolve(ws, evidence.file);
1296
+ if (!filePath.startsWith(ws + path.sep) && filePath !== ws) {
1297
+ return { finding, codeWindow: null };
1298
+ }
1299
+ if (!existsSync(filePath))
1300
+ return { finding, codeWindow: null };
1301
+ const fullText = await fs.readFile(filePath, 'utf-8');
1302
+ const lines = fullText.split(/\r?\n/);
1303
+ const start = Math.max(1, evidence.range.startLine - contextLines);
1304
+ const end = Math.min(lines.length, evidence.range.endLine + contextLines);
1305
+ const windowLines = lines.slice(start - 1, end);
1306
+ return {
1307
+ finding,
1308
+ codeWindow: {
1309
+ file: evidence.file,
1310
+ startLine: start,
1311
+ endLine: end,
1312
+ highlightFrom: evidence.range.startLine,
1313
+ highlightTo: evidence.range.endLine,
1314
+ lines: windowLines,
1315
+ },
1316
+ };
1317
+ });
1318
+ app.get('/api/symbols/tree', async () => {
1319
+ const parsed = await getOrParseWorkspace();
1320
+ // Index findings by file from the latest scan (in-memory live scan
1321
+ // wins; falls back to disk history).
1322
+ const findingsByFile = await loadLatestFindingsIndex();
1323
+ const symbolsByFile = new Map();
1324
+ for (const s of parsed.symbols) {
1325
+ symbolsByFile.set(s.file, (symbolsByFile.get(s.file) ?? 0) + 1);
1326
+ }
1327
+ const root = {
1328
+ name: '.',
1329
+ path: '',
1330
+ kind: 'dir',
1331
+ symbolCount: 0,
1332
+ h: 0,
1333
+ m: 0,
1334
+ l: 0,
1335
+ children: [],
1336
+ };
1337
+ for (const file of parsed.files) {
1338
+ const parts = file.split('/').filter(Boolean);
1339
+ let cur = root;
1340
+ for (let i = 0; i < parts.length; i++) {
1341
+ const part = parts[i];
1342
+ const isLast = i === parts.length - 1;
1343
+ const sub = cur.children.find((c) => c.name === part);
1344
+ if (sub) {
1345
+ cur = sub;
1346
+ }
1347
+ else {
1348
+ const child = {
1349
+ name: part,
1350
+ path: parts.slice(0, i + 1).join('/'),
1351
+ kind: isLast ? 'file' : 'dir',
1352
+ symbolCount: 0,
1353
+ h: 0,
1354
+ m: 0,
1355
+ l: 0,
1356
+ children: [],
1357
+ };
1358
+ cur.children.push(child);
1359
+ cur = child;
1360
+ }
1361
+ }
1362
+ const counts = findingsByFile.get(file);
1363
+ if (counts) {
1364
+ cur.h = counts.h;
1365
+ cur.m = counts.m;
1366
+ cur.l = counts.l;
1367
+ }
1368
+ cur.symbolCount = symbolsByFile.get(file) ?? 0;
1369
+ }
1370
+ // Bubble counts upward.
1371
+ function bubble(n) {
1372
+ let h = n.h;
1373
+ let m = n.m;
1374
+ let l = n.l;
1375
+ let s = n.symbolCount;
1376
+ for (const c of n.children) {
1377
+ const sub = bubble(c);
1378
+ h += sub.h;
1379
+ m += sub.m;
1380
+ l += sub.l;
1381
+ s += sub.s;
1382
+ }
1383
+ if (n.kind === 'dir') {
1384
+ n.h = h;
1385
+ n.m = m;
1386
+ n.l = l;
1387
+ n.symbolCount = s;
1388
+ }
1389
+ return { h, m, l, s };
1390
+ }
1391
+ bubble(root);
1392
+ // Sort directories first, then alphabetical inside each tier.
1393
+ function sort(n) {
1394
+ n.children.sort((a, b) => {
1395
+ if (a.kind !== b.kind)
1396
+ return a.kind === 'dir' ? -1 : 1;
1397
+ return a.name.localeCompare(b.name);
1398
+ });
1399
+ n.children.forEach(sort);
1400
+ }
1401
+ sort(root);
1402
+ return root;
1403
+ });
1404
+ app.get('/api/symbols/file', async (req, reply) => {
1405
+ const file = req.query.path;
1406
+ if (!file)
1407
+ return reply.code(400).send({ error: 'path query param required' });
1408
+ const parsed = await getOrParseWorkspace();
1409
+ const symbols = parsed.symbols.filter((s) => s.file === file);
1410
+ // In/out edges at file level — count import records linking this file
1411
+ // to others. Symbol-level call graph would need ts-morph
1412
+ // findReferencesAsNodes; not yet wired.
1413
+ const inFiles = new Set();
1414
+ const outFiles = new Set();
1415
+ for (const imp of parsed.imports) {
1416
+ if (!imp.target)
1417
+ continue;
1418
+ if (imp.target === file)
1419
+ inFiles.add(imp.source);
1420
+ if (imp.source === file && imp.target !== file)
1421
+ outFiles.add(imp.target);
1422
+ }
1423
+ const findingsByFile = await loadLatestFindingsIndex();
1424
+ const counts = findingsByFile.get(file) ?? { h: 0, m: 0, l: 0 };
1425
+ return {
1426
+ file,
1427
+ symbolCount: symbols.length,
1428
+ h: counts.h,
1429
+ m: counts.m,
1430
+ l: counts.l,
1431
+ inFiles: inFiles.size,
1432
+ outFiles: outFiles.size,
1433
+ symbols: symbols.map((s) => ({
1434
+ id: s.id,
1435
+ name: s.name,
1436
+ kind: s.kind,
1437
+ line: s.range.startLine,
1438
+ exported: s.exported,
1439
+ // Edge counts at file granularity attributed to each symbol; a
1440
+ // future symbol-level resolver will refine these.
1441
+ in: inFiles.size,
1442
+ out: outFiles.size,
1443
+ })),
1444
+ };
1445
+ });
1446
+ app.get('/api/symbols/:name', async (req, reply) => {
1447
+ const name = decodeURIComponent(req.params.name);
1448
+ const file = req.query.file;
1449
+ const parsed = await getOrParseWorkspace();
1450
+ const matches = parsed.symbols.filter((s) => s.name === name && (!file || s.file === file));
1451
+ if (matches.length === 0)
1452
+ return reply.code(404).send({ error: 'symbol not found' });
1453
+ const pick = matches[0];
1454
+ const callers = [];
1455
+ const callees = [];
1456
+ for (const imp of parsed.imports) {
1457
+ if (!imp.target)
1458
+ continue;
1459
+ if (imp.target === pick.file && imp.source !== pick.file) {
1460
+ // Only count the import if it actually pulled this symbol.
1461
+ const consumed = imp.namedImports.includes(pick.name) || imp.defaultImport === pick.name || imp.namespaceAlias;
1462
+ if (consumed)
1463
+ callers.push(imp.source);
1464
+ }
1465
+ if (imp.source === pick.file && imp.target !== pick.file) {
1466
+ callees.push(imp.target);
1467
+ }
1468
+ }
1469
+ return {
1470
+ name: pick.name,
1471
+ kind: pick.kind,
1472
+ file: pick.file,
1473
+ line: pick.range.startLine,
1474
+ exported: pick.exported,
1475
+ signature: pick.source.split('\n').slice(0, 3).join('\n'),
1476
+ callers: dedup(callers),
1477
+ callees: dedup(callees),
1478
+ };
1479
+ });
1480
+ function dedup(xs) {
1481
+ return [...new Set(xs)];
1482
+ }
1483
+ async function loadLatestFindingsIndex() {
1484
+ const out = new Map();
1485
+ // Prefer the in-memory latest done scan, fall back to disk.
1486
+ let findings;
1487
+ for (const s of scans.values()) {
1488
+ if (s.state === 'done' && s.findings && s.findings.length > 0) {
1489
+ findings = s.findings;
1490
+ break;
1491
+ }
1492
+ }
1493
+ if (!findings) {
1494
+ const hist = await loadScanHistory(getWorkspaceRoot());
1495
+ findings = hist.find((s) => s.findings && s.findings.length > 0)?.findings;
1496
+ }
1497
+ if (!findings)
1498
+ return out;
1499
+ for (const f of findings) {
1500
+ const file = f.evidence[0]?.file;
1501
+ if (!file)
1502
+ continue;
1503
+ const r = out.get(file) ?? { h: 0, m: 0, l: 0 };
1504
+ if (f.severity === 'high')
1505
+ r.h += 1;
1506
+ else if (f.severity === 'medium')
1507
+ r.m += 1;
1508
+ else
1509
+ r.l += 1;
1510
+ out.set(file, r);
1511
+ }
1512
+ return out;
1513
+ }
1514
+ // Static UI (built artifacts) + SPA fallback. Any non-/api/* path that
1515
+ // doesn't match a static file returns index.html so the client-side
1516
+ // router can handle it. This lets users deep-link to /findings,
1517
+ // /finding/<fp>, /scan/<id>, etc. and use the browser back button.
1518
+ if (existsSync(UI_DIST)) {
1519
+ await app.register(import('@fastify/static'), { root: UI_DIST, prefix: '/' });
1520
+ app.setNotFoundHandler(async (req, reply) => {
1521
+ if (req.url.startsWith('/api/')) {
1522
+ return reply.code(404).send({ error: 'not found' });
1523
+ }
1524
+ return reply.type('text/html').sendFile('index.html');
1525
+ });
1526
+ }
1527
+ await app.listen({ port: PORT, host: HOST });
1528
+ logger.info({ port: PORT, host: HOST, workspaceRoot: getWorkspaceRoot(), fsAllowRoots: FS_ALLOW_ROOTS }, 'RotHunter server listening');
1529
+ //# sourceMappingURL=index.js.map