@quickchatai/quickreview 1.1.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (471) hide show
  1. package/README.md +137 -57
  2. package/bin/qcr.js +1 -1
  3. package/config.example.toml +60 -14
  4. package/deploy/ansible/README.md +20 -12
  5. package/deploy/ansible/inventory.example.ini +1 -1
  6. package/deploy/collect-secrets.sh +1 -1
  7. package/deploy/qcr-update.sh +1 -1
  8. package/dist/extensions/cli/home.js +14 -0
  9. package/dist/extensions/cli/home.js.map +1 -0
  10. package/dist/extensions/cli/login-route.js +5 -0
  11. package/dist/extensions/cli/login-route.js.map +1 -0
  12. package/dist/extensions/cli/selfimprove.js +18 -0
  13. package/dist/extensions/cli/selfimprove.js.map +1 -0
  14. package/dist/extensions/home.js +350 -0
  15. package/dist/extensions/home.js.map +1 -0
  16. package/dist/extensions/login-route.js +180 -0
  17. package/dist/extensions/login-route.js.map +1 -0
  18. package/dist/extensions/onboarding.js +35 -11
  19. package/dist/extensions/onboarding.js.map +1 -1
  20. package/dist/extensions/selfimprove.js +49 -0
  21. package/dist/extensions/selfimprove.js.map +1 -0
  22. package/dist/extensions/setup-scan.js +23 -592
  23. package/dist/extensions/setup-scan.js.map +1 -1
  24. package/dist/extensions/setup.js +2 -47
  25. package/dist/extensions/setup.js.map +1 -1
  26. package/dist/src/archive/restore.js +18 -15
  27. package/dist/src/archive/restore.js.map +1 -1
  28. package/dist/src/botworker.js +13 -2
  29. package/dist/src/botworker.js.map +1 -1
  30. package/dist/src/channelfs.js +3 -26
  31. package/dist/src/channelfs.js.map +1 -1
  32. package/dist/src/ciretry.js +2 -1
  33. package/dist/src/ciretry.js.map +1 -1
  34. package/dist/src/cli.js +54 -61
  35. package/dist/src/cli.js.map +1 -1
  36. package/dist/src/cliops.js +3 -2
  37. package/dist/src/cliops.js.map +1 -1
  38. package/dist/src/commands.js +25 -2
  39. package/dist/src/commands.js.map +1 -1
  40. package/dist/src/config.js +32 -2
  41. package/dist/src/config.js.map +1 -1
  42. package/dist/src/daemon.js +50 -8
  43. package/dist/src/daemon.js.map +1 -1
  44. package/dist/src/debuground.js +1 -0
  45. package/dist/src/debuground.js.map +1 -1
  46. package/dist/src/debugroundpi.js +6 -1
  47. package/dist/src/debugroundpi.js.map +1 -1
  48. package/dist/src/dispatch.js +5 -4
  49. package/dist/src/dispatch.js.map +1 -1
  50. package/dist/src/engine/caserunner-sdk.js +15 -251
  51. package/dist/src/engine/caserunner-sdk.js.map +1 -1
  52. package/dist/src/engine/debugpi.js +17 -5
  53. package/dist/src/engine/debugpi.js.map +1 -1
  54. package/dist/src/execboundary.js +10 -3
  55. package/dist/src/execboundary.js.map +1 -1
  56. package/dist/src/github.js +51 -16
  57. package/dist/src/github.js.map +1 -1
  58. package/dist/src/githubtoken.js +346 -0
  59. package/dist/src/githubtoken.js.map +1 -0
  60. package/dist/src/home/adapters.js +349 -0
  61. package/dist/src/home/adapters.js.map +1 -0
  62. package/dist/src/home/advanced.js +318 -0
  63. package/dist/src/home/advanced.js.map +1 -0
  64. package/dist/src/home/advancedtools.js +57 -0
  65. package/dist/src/home/advancedtools.js.map +1 -0
  66. package/dist/src/home/autoreview.js +325 -0
  67. package/dist/src/home/autoreview.js.map +1 -0
  68. package/dist/src/home/daemonstatus.js +32 -0
  69. package/dist/src/home/daemonstatus.js.map +1 -0
  70. package/dist/src/home/devicelogin.js +61 -0
  71. package/dist/src/home/devicelogin.js.map +1 -0
  72. package/dist/src/home/launch.js +202 -0
  73. package/dist/src/home/launch.js.map +1 -0
  74. package/dist/src/home/loginowner.js +205 -0
  75. package/dist/src/home/loginowner.js.map +1 -0
  76. package/dist/src/home/memory.js +122 -0
  77. package/dist/src/home/memory.js.map +1 -0
  78. package/dist/src/home/model.js +811 -0
  79. package/dist/src/home/model.js.map +1 -0
  80. package/dist/src/home/panel.js +520 -0
  81. package/dist/src/home/panel.js.map +1 -0
  82. package/dist/src/home/ports.js +19 -0
  83. package/dist/src/home/ports.js.map +1 -0
  84. package/dist/src/home/review.js +163 -0
  85. package/dist/src/home/review.js.map +1 -0
  86. package/dist/src/home/reviews.js +478 -0
  87. package/dist/src/home/reviews.js.map +1 -0
  88. package/dist/src/home/setup.js +507 -0
  89. package/dist/src/home/setup.js.map +1 -0
  90. package/dist/src/home/setupadapters.js +214 -0
  91. package/dist/src/home/setupadapters.js.map +1 -0
  92. package/dist/src/home/setupcard.js +199 -0
  93. package/dist/src/home/setupcard.js.map +1 -0
  94. package/dist/src/home/setuptools.js +45 -0
  95. package/dist/src/home/setuptools.js.map +1 -0
  96. package/dist/src/home/spend.js +160 -0
  97. package/dist/src/home/spend.js.map +1 -0
  98. package/dist/src/home/switches.js +209 -0
  99. package/dist/src/home/switches.js.map +1 -0
  100. package/dist/src/home/switchon.js +439 -0
  101. package/dist/src/home/switchon.js.map +1 -0
  102. package/dist/src/home/toolnames.js +41 -0
  103. package/dist/src/home/toolnames.js.map +1 -0
  104. package/dist/src/home/tune.js +341 -0
  105. package/dist/src/home/tune.js.map +1 -0
  106. package/dist/src/home/words.js +7 -0
  107. package/dist/src/home/words.js.map +1 -0
  108. package/dist/src/homes.js +14 -0
  109. package/dist/src/homes.js.map +1 -1
  110. package/dist/src/install.js +1 -1
  111. package/dist/src/install.js.map +1 -1
  112. package/dist/src/ipc/workerspec.js +8 -0
  113. package/dist/src/ipc/workerspec.js.map +1 -1
  114. package/dist/src/live/publish.js +1 -1
  115. package/dist/src/live/publish.js.map +1 -1
  116. package/dist/src/livecli.js +1 -1
  117. package/dist/src/livecli.js.map +1 -1
  118. package/dist/src/loginhint.js +8 -3
  119. package/dist/src/loginhint.js.map +1 -1
  120. package/dist/src/loginrefresh/handover.js +121 -0
  121. package/dist/src/loginrefresh/handover.js.map +1 -0
  122. package/dist/src/loginrefresh/lock.js +54 -3
  123. package/dist/src/loginrefresh/lock.js.map +1 -1
  124. package/dist/src/loginrefresh/pirefresh.js +3 -0
  125. package/dist/src/loginrefresh/pirefresh.js.map +1 -1
  126. package/dist/src/loginrefresh/refresher.js +33 -5
  127. package/dist/src/loginrefresh/refresher.js.map +1 -1
  128. package/dist/src/loginrefresh/supervisor.js +18 -1
  129. package/dist/src/loginrefresh/supervisor.js.map +1 -1
  130. package/dist/src/npmverbs.js +46 -14
  131. package/dist/src/npmverbs.js.map +1 -1
  132. package/dist/src/onboard/choices.js +35 -4
  133. package/dist/src/onboard/choices.js.map +1 -1
  134. package/dist/src/onboard/engine.js +32 -306
  135. package/dist/src/onboard/engine.js.map +1 -1
  136. package/dist/src/onboard/pipin.js +22 -0
  137. package/dist/src/onboard/pipin.js.map +1 -0
  138. package/dist/src/onboard/stepforward.js +41 -0
  139. package/dist/src/onboard/stepforward.js.map +1 -0
  140. package/dist/src/onboard/steps/appslug.js +4 -1
  141. package/dist/src/onboard/steps/appslug.js.map +1 -1
  142. package/dist/src/onboard/steps/bringup.js +3 -2
  143. package/dist/src/onboard/steps/bringup.js.map +1 -1
  144. package/dist/src/onboard/steps/compose.js +29 -0
  145. package/dist/src/onboard/steps/compose.js.map +1 -1
  146. package/dist/src/onboard/steps/configfill.js +120 -39
  147. package/dist/src/onboard/steps/configfill.js.map +1 -1
  148. package/dist/src/onboard/steps/githubaccount.js +595 -0
  149. package/dist/src/onboard/steps/githubaccount.js.map +1 -0
  150. package/dist/src/onboard/steps/githubapp.js +65 -7
  151. package/dist/src/onboard/steps/githubapp.js.map +1 -1
  152. package/dist/src/onboard/steps/golive.js +2 -0
  153. package/dist/src/onboard/steps/golive.js.map +1 -1
  154. package/dist/src/onboard/steps/index.js +11 -0
  155. package/dist/src/onboard/steps/index.js.map +1 -1
  156. package/dist/src/onboard/steps/ingress.js +80 -25
  157. package/dist/src/onboard/steps/ingress.js.map +1 -1
  158. package/dist/src/onboard/steps/llmauth.js +103 -30
  159. package/dist/src/onboard/steps/llmauth.js.map +1 -1
  160. package/dist/src/onboard/steps/loginshare.js +213 -0
  161. package/dist/src/onboard/steps/loginshare.js.map +1 -0
  162. package/dist/src/onboard/steps/memory.js +67 -3
  163. package/dist/src/onboard/steps/memory.js.map +1 -1
  164. package/dist/src/onboard/steps/proof.js +366 -56
  165. package/dist/src/onboard/steps/proof.js.map +1 -1
  166. package/dist/src/onboard/steps/repoconfig.js +72 -0
  167. package/dist/src/onboard/steps/repoconfig.js.map +1 -0
  168. package/dist/src/onboard/steps/topology.js +6 -6
  169. package/dist/src/onboard/steps/topology.js.map +1 -1
  170. package/dist/src/onboard/steps/transport.js +9 -1
  171. package/dist/src/onboard/steps/transport.js.map +1 -1
  172. package/dist/src/onboard/steps/tuiroute.js +102 -0
  173. package/dist/src/onboard/steps/tuiroute.js.map +1 -0
  174. package/dist/src/onboard/steps/verifyup.js +17 -1
  175. package/dist/src/onboard/steps/verifyup.js.map +1 -1
  176. package/dist/src/onboard/toolnames.js +30 -0
  177. package/dist/src/onboard/toolnames.js.map +1 -0
  178. package/dist/src/operatorwrite.js +23 -0
  179. package/dist/src/operatorwrite.js.map +1 -0
  180. package/dist/src/outcomes.js +4 -1
  181. package/dist/src/outcomes.js.map +1 -1
  182. package/dist/src/piagentdir.js +13 -0
  183. package/dist/src/piagentdir.js.map +1 -0
  184. package/dist/src/pilogin.js +31 -16
  185. package/dist/src/pilogin.js.map +1 -1
  186. package/dist/src/posting-journal.js +2 -1
  187. package/dist/src/posting-journal.js.map +1 -1
  188. package/dist/src/posting-outbox.js +6 -2
  189. package/dist/src/posting-outbox.js.map +1 -1
  190. package/dist/src/posting-receipt.js +14 -1
  191. package/dist/src/posting-receipt.js.map +1 -1
  192. package/dist/src/qcrbin.js +16 -11
  193. package/dist/src/qcrbin.js.map +1 -1
  194. package/dist/src/recheck.js +2 -1
  195. package/dist/src/recheck.js.map +1 -1
  196. package/dist/src/reconcile.js +22 -2
  197. package/dist/src/reconcile.js.map +1 -1
  198. package/dist/src/reviewargs.js +122 -0
  199. package/dist/src/reviewargs.js.map +1 -0
  200. package/dist/src/reviewcli.js +7 -161
  201. package/dist/src/reviewcli.js.map +1 -1
  202. package/dist/src/reviewdetach.js +76 -11
  203. package/dist/src/reviewdetach.js.map +1 -1
  204. package/dist/src/reviewhistory.js +32 -6
  205. package/dist/src/reviewhistory.js.map +1 -1
  206. package/dist/src/reviewnow.js +45 -0
  207. package/dist/src/reviewnow.js.map +1 -0
  208. package/dist/src/scancli.js +39 -691
  209. package/dist/src/scancli.js.map +1 -1
  210. package/dist/src/selfimprove.js +66 -0
  211. package/dist/src/selfimprove.js.map +1 -0
  212. package/dist/src/setup/adapters.js +5 -223
  213. package/dist/src/setup/adapters.js.map +1 -1
  214. package/dist/src/setup/caserunner.js +15 -249
  215. package/dist/src/setup/caserunner.js.map +1 -1
  216. package/dist/src/setup/engine.js +32 -341
  217. package/dist/src/setup/engine.js.map +1 -1
  218. package/dist/src/setup/facts.js +3 -592
  219. package/dist/src/setup/facts.js.map +1 -1
  220. package/dist/src/setup/findings.js +3 -947
  221. package/dist/src/setup/findings.js.map +1 -1
  222. package/dist/src/setup/presentation.js +3 -8
  223. package/dist/src/setup/presentation.js.map +1 -1
  224. package/dist/src/setup/scan.js +3 -724
  225. package/dist/src/setup/scan.js.map +1 -1
  226. package/dist/src/setup/scanbuild.js +9 -92
  227. package/dist/src/setup/scanbuild.js.map +1 -1
  228. package/dist/src/setup/scanlease.js +3 -250
  229. package/dist/src/setup/scanlease.js.map +1 -1
  230. package/dist/src/setup/scanreport.js +3 -288
  231. package/dist/src/setup/scanreport.js.map +1 -1
  232. package/dist/src/setup/scanrun.js +3 -1732
  233. package/dist/src/setup/scanrun.js.map +1 -1
  234. package/dist/src/setup/scantree.js +3 -256
  235. package/dist/src/setup/scantree.js.map +1 -1
  236. package/dist/src/setup/stage2request.js +3 -258
  237. package/dist/src/setup/stage2request.js.map +1 -1
  238. package/dist/src/setup/unitcut.js +3 -436
  239. package/dist/src/setup/unitcut.js.map +1 -1
  240. package/dist/src/state.js +2 -0
  241. package/dist/src/state.js.map +1 -1
  242. package/dist/src/statemachine.js +4 -1
  243. package/dist/src/statemachine.js.map +1 -1
  244. package/dist/src/takeover.js +1 -0
  245. package/dist/src/takeover.js.map +1 -1
  246. package/dist/src/takeoverpi.js +15 -1
  247. package/dist/src/takeoverpi.js.map +1 -1
  248. package/dist/src/tui/app.js +49 -0
  249. package/dist/src/tui/app.js.map +1 -1
  250. package/dist/src/tui/board.js +88 -12
  251. package/dist/src/tui/board.js.map +1 -1
  252. package/dist/src/tui/cmdbar.js +4 -1
  253. package/dist/src/tui/cmdbar.js.map +1 -1
  254. package/dist/src/tui/companion.js +76 -11
  255. package/dist/src/tui/companion.js.map +1 -1
  256. package/dist/src/tui/connect.js +7 -1
  257. package/dist/src/tui/connect.js.map +1 -1
  258. package/dist/src/tui/deploytab.js +2 -2
  259. package/dist/src/tui/deploytab.js.map +1 -1
  260. package/dist/src/tui/filters.js +5 -2
  261. package/dist/src/tui/filters.js.map +1 -1
  262. package/dist/src/tui/ghread.js +61 -5
  263. package/dist/src/tui/ghread.js.map +1 -1
  264. package/dist/src/tui/index.js +109 -20
  265. package/dist/src/tui/index.js.map +1 -1
  266. package/dist/src/tui/memforward.js +39 -2
  267. package/dist/src/tui/memforward.js.map +1 -1
  268. package/dist/src/tui/memory.js +3 -16
  269. package/dist/src/tui/memory.js.map +1 -1
  270. package/dist/src/tui/memoryurl.js +22 -0
  271. package/dist/src/tui/memoryurl.js.map +1 -0
  272. package/dist/src/tui/model.js +25 -0
  273. package/dist/src/tui/model.js.map +1 -1
  274. package/dist/src/tui/prpause.js +7 -0
  275. package/dist/src/tui/prpause.js.map +1 -1
  276. package/dist/src/tui/remote.js +7 -1
  277. package/dist/src/tui/remote.js.map +1 -1
  278. package/dist/src/tui/route.js +143 -0
  279. package/dist/src/tui/route.js.map +1 -0
  280. package/dist/src/tui/setupstate.js +32 -0
  281. package/dist/src/tui/setupstate.js.map +1 -0
  282. package/dist/src/tui/sshtriage.js +102 -5
  283. package/dist/src/tui/sshtriage.js.map +1 -1
  284. package/dist/src/tui/theme.js +2 -1
  285. package/dist/src/tui/theme.js.map +1 -1
  286. package/dist/src/tui/unwedge.js +2 -12
  287. package/dist/src/tui/unwedge.js.map +1 -1
  288. package/dist/src/verdicts.js +17 -5
  289. package/dist/src/verdicts.js.map +1 -1
  290. package/dist/src/version.js +1 -1
  291. package/dist/src/version.js.map +1 -1
  292. package/dist/src/webhook.js +12 -1
  293. package/dist/src/webhook.js.map +1 -1
  294. package/dist/src/wire.js +34 -8
  295. package/dist/src/wire.js.map +1 -1
  296. package/dist/src/workerworld.js +11 -1
  297. package/dist/src/workerworld.js.map +1 -1
  298. package/docker/README.md +1 -1
  299. package/docker/companion/entrypoint.sh +20 -1
  300. package/docker/entrypoint.sh +57 -6
  301. package/docker-compose.yml +16 -1
  302. package/extensions/cli/home.ts +14 -0
  303. package/extensions/cli/login-route.ts +10 -0
  304. package/extensions/cli/selfimprove.ts +18 -0
  305. package/extensions/home.ts +484 -0
  306. package/extensions/login-route.ts +220 -0
  307. package/extensions/onboarding.ts +41 -11
  308. package/extensions/selfimprove.ts +65 -0
  309. package/extensions/setup-scan.ts +79 -766
  310. package/extensions/setup.ts +14 -70
  311. package/package.json +6 -4
  312. package/prompts/onboarding.md +58 -18
  313. package/schemas/review-result.v1.json +60 -0
  314. package/scripts/deploy.sh +8 -6
  315. package/scripts/mint-token.sh +25 -4
  316. package/scripts/qcr-host.sh +28 -5
  317. package/scripts/vm-ghcr-login.sh +7 -4
  318. package/src/archive/restore.ts +18 -21
  319. package/src/botworker.ts +13 -2
  320. package/src/channelfs.ts +3 -26
  321. package/src/ciretry.ts +2 -1
  322. package/src/cli.ts +59 -67
  323. package/src/cliops.ts +3 -2
  324. package/src/commands.ts +28 -2
  325. package/src/config.ts +62 -4
  326. package/src/daemon.ts +58 -7
  327. package/src/debuground.ts +3 -0
  328. package/src/debugroundpi.ts +12 -1
  329. package/src/dispatch.ts +5 -4
  330. package/src/engine/caserunner-sdk.ts +29 -314
  331. package/src/engine/debugpi.ts +17 -5
  332. package/src/execboundary.ts +16 -4
  333. package/src/github.ts +57 -15
  334. package/src/githubtoken.ts +435 -0
  335. package/src/home/adapters.ts +418 -0
  336. package/src/home/advanced.ts +421 -0
  337. package/src/home/advancedtools.ts +96 -0
  338. package/src/home/autoreview.ts +385 -0
  339. package/src/home/daemonstatus.ts +55 -0
  340. package/src/home/devicelogin.ts +107 -0
  341. package/src/home/launch.ts +257 -0
  342. package/src/home/loginowner.ts +248 -0
  343. package/src/home/memory.ts +176 -0
  344. package/src/home/model.ts +965 -0
  345. package/src/home/panel.ts +666 -0
  346. package/src/home/ports.ts +338 -0
  347. package/src/home/review.ts +206 -0
  348. package/src/home/reviews.ts +565 -0
  349. package/src/home/setup.ts +689 -0
  350. package/src/home/setupadapters.ts +217 -0
  351. package/src/home/setupcard.ts +288 -0
  352. package/src/home/setuptools.ts +57 -0
  353. package/src/home/spend.ts +194 -0
  354. package/src/home/switches.ts +293 -0
  355. package/src/home/switchon.ts +554 -0
  356. package/src/home/toolnames.ts +46 -0
  357. package/src/home/tune.ts +405 -0
  358. package/src/home/words.ts +8 -0
  359. package/src/homes.ts +17 -0
  360. package/src/install.ts +1 -1
  361. package/src/ipc/workerspec.ts +15 -0
  362. package/src/live/publish.ts +4 -1
  363. package/src/livecli.ts +6 -1
  364. package/src/loginhint.ts +8 -3
  365. package/src/loginrefresh/handover.ts +138 -0
  366. package/src/loginrefresh/lock.ts +72 -3
  367. package/src/loginrefresh/pirefresh.ts +3 -0
  368. package/src/loginrefresh/refresher.ts +34 -5
  369. package/src/loginrefresh/supervisor.ts +25 -2
  370. package/src/npmverbs.ts +55 -13
  371. package/src/onboard/choices.ts +40 -4
  372. package/src/onboard/engine.ts +70 -421
  373. package/src/onboard/pipin.ts +31 -0
  374. package/src/onboard/stepforward.ts +61 -0
  375. package/src/onboard/steps/appslug.ts +4 -1
  376. package/src/onboard/steps/bringup.ts +3 -2
  377. package/src/onboard/steps/compose.ts +31 -0
  378. package/src/onboard/steps/configfill.ts +144 -40
  379. package/src/onboard/steps/githubaccount.ts +716 -0
  380. package/src/onboard/steps/githubapp.ts +92 -8
  381. package/src/onboard/steps/golive.ts +2 -0
  382. package/src/onboard/steps/index.ts +11 -0
  383. package/src/onboard/steps/ingress.ts +98 -31
  384. package/src/onboard/steps/llmauth.ts +114 -29
  385. package/src/onboard/steps/loginshare.ts +258 -0
  386. package/src/onboard/steps/memory.ts +76 -3
  387. package/src/onboard/steps/proof.ts +473 -55
  388. package/src/onboard/steps/repoconfig.ts +85 -0
  389. package/src/onboard/steps/topology.ts +7 -6
  390. package/src/onboard/steps/transport.ts +15 -0
  391. package/src/onboard/steps/tuiroute.ts +119 -0
  392. package/src/onboard/steps/verifyup.ts +20 -1
  393. package/src/onboard/toolnames.ts +34 -0
  394. package/src/operatorwrite.ts +25 -0
  395. package/src/outcomes.ts +8 -1
  396. package/src/piagentdir.ts +12 -0
  397. package/src/pilogin.ts +32 -16
  398. package/src/posting-journal.ts +6 -1
  399. package/src/posting-outbox.ts +10 -2
  400. package/src/posting-receipt.ts +17 -0
  401. package/src/qcrbin.ts +16 -11
  402. package/src/recheck.ts +2 -1
  403. package/src/reconcile.ts +29 -3
  404. package/src/reviewargs.ts +119 -0
  405. package/src/reviewcli.ts +6 -167
  406. package/src/reviewdetach.ts +107 -13
  407. package/src/reviewhistory.ts +20 -6
  408. package/src/reviewnow.ts +76 -0
  409. package/src/scancli.ts +75 -826
  410. package/src/selfimprove.ts +90 -0
  411. package/src/setup/adapters.ts +13 -221
  412. package/src/setup/caserunner.ts +34 -273
  413. package/src/setup/engine.ts +62 -496
  414. package/src/setup/facts.ts +3 -627
  415. package/src/setup/findings.ts +3 -1264
  416. package/src/setup/presentation.ts +3 -18
  417. package/src/setup/scan.ts +3 -1033
  418. package/src/setup/scanbuild.ts +20 -114
  419. package/src/setup/scanlease.ts +3 -297
  420. package/src/setup/scanreport.ts +3 -373
  421. package/src/setup/scanrun.ts +3 -2268
  422. package/src/setup/scantree.ts +3 -259
  423. package/src/setup/stage2request.ts +3 -347
  424. package/src/setup/unitcut.ts +3 -523
  425. package/src/state.ts +43 -0
  426. package/src/statemachine.ts +4 -1
  427. package/src/takeover.ts +5 -0
  428. package/src/takeoverpi.ts +16 -2
  429. package/src/tui/app.ts +47 -0
  430. package/src/tui/board.ts +111 -14
  431. package/src/tui/cmdbar.ts +4 -1
  432. package/src/tui/companion.ts +80 -11
  433. package/src/tui/connect.ts +7 -1
  434. package/src/tui/deploytab.ts +2 -2
  435. package/src/tui/filters.ts +7 -3
  436. package/src/tui/ghread.ts +74 -7
  437. package/src/tui/index.ts +117 -23
  438. package/src/tui/memforward.ts +52 -3
  439. package/src/tui/memory.ts +3 -15
  440. package/src/tui/memoryurl.ts +22 -0
  441. package/src/tui/model.ts +36 -2
  442. package/src/tui/prpause.ts +11 -2
  443. package/src/tui/remote.ts +9 -1
  444. package/src/tui/route.ts +173 -0
  445. package/src/tui/setupstate.ts +56 -0
  446. package/src/tui/sshtriage.ts +127 -6
  447. package/src/tui/theme.ts +2 -1
  448. package/src/tui/unwedge.ts +2 -14
  449. package/src/verdicts.ts +23 -5
  450. package/src/version.ts +1 -1
  451. package/src/webhook.ts +10 -1
  452. package/src/wire.ts +42 -9
  453. package/src/workerworld.ts +16 -2
  454. package/dist/src/live/projection.js +0 -450
  455. package/dist/src/live/projection.js.map +0 -1
  456. package/dist/src/live/read.js +0 -60
  457. package/dist/src/live/read.js.map +0 -1
  458. package/dist/src/onboard/launch.js +0 -83
  459. package/dist/src/onboard/launch.js.map +0 -1
  460. package/dist/src/setup/ledger.js +0 -207
  461. package/dist/src/setup/ledger.js.map +0 -1
  462. package/dist/src/setup/stage2.js +0 -617
  463. package/dist/src/setup/stage2.js.map +0 -1
  464. package/dist/src/setup/textsniff.js +0 -89
  465. package/dist/src/setup/textsniff.js.map +0 -1
  466. package/src/live/projection.ts +0 -491
  467. package/src/live/read.ts +0 -86
  468. package/src/onboard/launch.ts +0 -115
  469. package/src/setup/ledger.ts +0 -324
  470. package/src/setup/stage2.ts +0 -926
  471. package/src/setup/textsniff.ts +0 -95
@@ -1,1264 +1,3 @@
1
- // The SCAN FINDINGS STORE (spec "Findings"): every finding
2
- // a scan has reported, keyed by its fingerprint — file + lens + kind + a hash
3
- // of the anchored code, whitespace-normalised (scan.ts findingFingerprint).
4
- //
5
- // Recognition across re-scans is two steps, and only the second
6
- // costs judgement:
7
- //
8
- // * EXACT MATCH FIRST. A finding whose fingerprint is already stored is the
9
- // same finding: its last sighting and wording are refreshed, and its
10
- // state — `new`, `promoted`, `dismissed` — is kept, so nothing is ever
11
- // triaged twice. (A `fixed` finding seen again has come back: it is
12
- // `new` again — or still `promoted` while its ticket is open.)
13
- // * THE COMPANION JUDGES THE REST. After a run, `reconcile` finds the
14
- // stored findings the re-scan no longer reports: their file was read
15
- // again at other content — the content the run's tree holds (the ledger
16
- // says so) — and no exact match came back, or their file is gone from
17
- // the tree. One that no fresh finding of a lens that raised it (in its
18
- // unit, or of its kind) could possibly be is
19
- // `fixed` at once. The rest are held with those fresh candidates in ONE
20
- // pending MATCH QUESTION, which the companion answers whole (`match`,
21
- // quoting the question's key): the pairs it names are the same finding —
22
- // the old one keeps its state and history and takes the new wording,
23
- // position and fingerprint (the old fingerprint kept as an alias) — and
24
- // every old finding it did not pair is `fixed`. Nothing is ever fixed on
25
- // a guess, and nothing waits on the companion that an exact rule settles.
26
- // * A DISMISSAL IS STICKY. The operator's triage is never
27
- // overwritten by a scan: a dismissed finding every rule above would fix
28
- // stays `dismissed`, stamped `unreported` (the run and sha) for audit, and
29
- // is out of play until a scan reports it again — which clears the stamp
30
- // and keeps the dismissal (no scan reopens it; the operator's promote is
31
- // the only way out). One settle, `settleUnreported`, serves every path.
32
- // A legacy store's finding fixed FROM dismissed reads back as dismissed
33
- // (scanreport.ts `parseFindingsFile`).
34
- // * `fixed` NEEDS CHANGED CONTENT. Only a re-read of OTHER
35
- // content can settle a finding fixed. A later reading of the SAME content
36
- // it was last seen at that does not report it again (a narrower stage-2
37
- // row, a re-run — model variance) leaves it open and stamps
38
- // `not_reproduced`: how many such readings, and the latest — once per
39
- // reading, cleared by the next sighting. Triage can still dismiss it; a
40
- // promoted one's ticket stays open, because only `fixed` closes one.
41
- // * `fixed` RECORDS WHERE. The run and sha that stopped reporting it, and
42
- // the state it was fixed from — what a promoted finding's ticket is
43
- // closed with.
44
- // * "MAY NOT BE VALID" IS A FLAG, NOT A STATE. A finding the validator
45
- // doubted is stored with its `doubt` (word and reason) and shown as such;
46
- // a finding no validator looked at is `validated: false`. Neither is
47
- // dropped.
48
- // * EVERY JUDGED FINDING HAS A VALIDITY TIER (the scan validator is the
49
- // skeptic). `confirmed` — the skeptic traced a concrete
50
- // scenario (kept in `scenario`); `likely` — a real mechanism whose
51
- // reachability it could not trace all the way; `doubtful` — refuted,
52
- // class-matched, or anything it would have killed, carried in `doubt`
53
- // (word, reason, and the false-positive class when one matched). A
54
- // finding no skeptic judged — the validator off or failed, or stored
55
- // before tiers existed — has none and reads as UNKNOWN (scanreport.ts
56
- // `validityOf`), never a guessed tier. The tier travels with the finding
57
- // through an exact match (the latest JUDGED sighting wins — an unjudged
58
- // re-sighting of the same code keeps the judgement it has), an alias and
59
- // a near-duplicate fold (the best-supported sighting wins, and the doubt
60
- // follows the tier it chose, so a finding never reads doubtful with no
61
- // reason nor confirmed while flagged "may not be valid").
62
- // * Individual findings never become memory (spec "Scan memory") — this
63
- // store is the PC-side record, in the state home, never shipped.
64
- // * TRIAGE (triage.ts drives it): a DISMISSED finding carries its
65
- // reason and whether its memory note was written; a PROMOTED one carries
66
- // its TICKET — where it was filed (the issue tracker or a markdown file)
67
- // and, once a re-scan found it fixed, when that ticket was closed. The
68
- // ticket record is what makes a finding never filed twice: it survives a
69
- // match (the new fingerprint inherits it), a fix and a return.
70
- // * NEAR-DUPLICATES COLLAPSE. The fingerprint hashes the lens
71
- // and the anchored code, so one defect raised by two lenses — or by two
72
- // rows of one lens anchored a few lines apart — arrives under two
73
- // fingerprints. A fresh finding (no exact or alias match) that is the
74
- // same file, the same kind, within NEAR_LINES of a live finding the SAME
75
- // run sighted and says the same thing (`nearDuplicate`: same subject, stemmed token
76
- // overlap at CLAIM_SIMILARITY) is folded into it: the stored one keeps
77
- // its identity, wording and triage, takes the higher severity, lists the
78
- // lens in `lenses`, the absorbed sighting in `merged`, and its
79
- // fingerprint in `aliases` — so the next run's exact match, triage by
80
- // fingerprint and ticket stamps all still find it. Deterministic and
81
- // deliberately under-merging: different kinds, far ranges, different
82
- // subjects, a fixed finding or one the match question holds never fold,
83
- // and a later run's look-alike of an older sighting is the companion's
84
- // match question, never a fold. `reconcile` runs the same fold once more
85
- // over the run's fresh findings (`collapseRun`), for a duplicate whose
86
- // case ran before the sighting it duplicates. A collapsed finding's
87
- // severity is the highest its sightings' LATEST reports give, and it is
88
- // unreported (fixed, or put to the match question) only once every lens
89
- // that raised it has re-read other content.
90
- //
91
- // Persistence: `<state home>/scan/findings.json`. An unreadable store is
92
- // reported and refuses writes, like the ledger and the unit cut.
93
- import { createHash } from "node:crypto";
94
- import type { StateFs } from "./engine.js";
95
- import { readings, type LedgerEntry, type Reading } from "./ledger.js";
96
- import { SEVERITIES, type ScanSeverity } from "./scan.js";
97
- import type { Reachability } from "@quickchatai/pi-quickreview/src/severity.js";
98
- import {
99
- FINDING_STATES,
100
- FINDINGS_FILE,
101
- FINDINGS_VERSION,
102
- VALIDITY_ORDER,
103
- dismissedLookalike,
104
- goneDismissal,
105
- lookalikeRank,
106
- parseFindingsFile,
107
- validityOf,
108
- type Validity,
109
- type ValidityView,
110
- } from "./scanreport.js";
111
-
112
- export { FINDINGS_FILE, FINDINGS_VERSION, dismissedLookalike, lookalikeRank };
113
- export type { Validity, ValidityView };
114
-
115
- export { FINDING_STATES };
116
- export type FindingState = (typeof FINDING_STATES)[number];
117
-
118
- export interface Sighting {
119
- run: string;
120
- sha: string;
121
- at: string;
122
- }
123
-
124
- /** Where a finding stopped being reported, and what it was before. */
125
- export interface FixedMark extends Sighting {
126
- from: Exclude<FindingState, "fixed">;
127
- }
128
-
129
- export interface ScanFinding {
130
- /** sha256 hex — the identity. */
131
- fingerprint: string;
132
- unit: string;
133
- path: string;
134
- /** The file's content hash at the last sighting (absent on legacy
135
- * findings). */
136
- blob?: string | null;
137
- /** The anchored lines at the last sighting (1-based, inclusive). */
138
- start: number;
139
- end: number;
140
- lens: string;
141
- kind: string;
142
- severity: ScanSeverity;
143
- confidence: number | null;
144
- /** The lens's reachability (null: it wrote none, read as routine). Absent
145
- * on legacy findings. */
146
- reachability?: Reachability | null;
147
- /** What the lens said before the reachability cap lowered `severity`. */
148
- capped_from?: ScanSeverity | null;
149
- /** Whether `evidence` quotes the anchored code (`evidenceQuotesAnchor`) —
150
- * false marks a finding whose lines may not be the defect's. */
151
- anchor_quoted?: boolean;
152
- claim: string;
153
- evidence: string | null;
154
- fix: string | null;
155
- detail: string | null;
156
- state: FindingState;
157
- /** A validator looked at it (survived, or doubted). */
158
- validated: boolean;
159
- /** "May not be valid": the validator's doubt word and reason — and, from
160
- * the scan skeptic, the false-positive class it matched (a
161
- * SCAN_FALSE_POSITIVE_CLASSES slug; null or absent when none). */
162
- doubt: FindingDoubt | null;
163
- /** The skeptic's tier. Null or absent: no skeptic judged it — UNKNOWN. */
164
- validity?: Validity | null;
165
- /** The skeptic's scenario for a confirmed or likely finding: the traced
166
- * path from an entry point (confirmed), or what it could and could not
167
- * trace (likely). */
168
- scenario?: string | null;
169
- first_seen: Sighting;
170
- last_seen: Sighting;
171
- /** Scans that reported it. */
172
- seen: number;
173
- /** Set while (and only while) the state is `fixed`. */
174
- fixed?: FixedMark | null;
175
- /** A DISMISSED finding a re-scan settled as no longer reported (its file
176
- * re-read at other content, or gone, and nothing paired it): where — the
177
- * audit of what would otherwise have been `fixed`. The state stays
178
- * `dismissed` (the operator's triage is never overwritten by a scan);
179
- * cleared when a scan reports it again. Absent: never so settled. */
180
- unreported?: Sighting | null;
181
- /** Re-reads of the unchanged content it was last seen at that did not
182
- * report it again — the latest, and how many since its last sighting.
183
- * The finding stays open; cleared when a scan reports it again. */
184
- not_reproduced?: NotReproduced | null;
185
- /** Fingerprints this finding carried before the companion matched it to
186
- * a moved or reworded sighting, and those of near-duplicates collapsed
187
- * into it — a ticket stamped with one still finds it. */
188
- aliases?: string[];
189
- /** Every lens that raised it, its own first — set once a near-duplicate
190
- * from another report was collapsed into it (absent: just `lens`). */
191
- lenses?: string[];
192
- /** The near-duplicate sightings collapsed into it, as last reported. */
193
- merged?: MergedSighting[];
194
- /** Its OWN fingerprint's latest severity, kept once near-duplicates
195
- * collapsed into it (`severity` is then the highest of this and each
196
- * merged sighting's) — so the aggregate is recomputed from the latest
197
- * reports whichever sighting reports last. */
198
- own_severity?: ScanSeverity;
199
- /** Its own latest judgement, before aggregating merged sightings. */
200
- own_judgement?: Judgement;
201
- /** Why the operator dismissed it; kept after a later promote. */
202
- dismissal?: Dismissal | null;
203
- /** Where it was filed when promoted. */
204
- ticket?: FindingTicket | null;
205
- /** It resembles (`nearDuplicate`) a finding the operator DISMISSED: that
206
- * finding and its dismissal's reason. A flag only — it stays
207
- * `new` for the operator to triage; read through `dismissedLookalike`.
208
- * Absent or null: it resembles none. */
209
- resembles_dismissed?: ResemblesDismissed | null;
210
- }
211
-
212
- export interface ResemblesDismissed {
213
- fingerprint: string;
214
- reason: string;
215
- }
216
-
217
- export interface FindingDoubt {
218
- word: string;
219
- reason: string;
220
- class?: string | null;
221
- }
222
-
223
- export interface NotReproduced extends Sighting {
224
- count: number;
225
- }
226
-
227
- type Judgement = Pick<ScanFinding, "validated" | "doubt" | "validity" | "scenario">;
228
-
229
- export interface MergedSighting extends Partial<Judgement> {
230
- fingerprint: string;
231
- lens: string;
232
- start: number;
233
- end: number;
234
- severity: ScanSeverity;
235
- claim: string;
236
- }
237
-
238
- export interface Dismissal {
239
- reason: string;
240
- at: string;
241
- /** The unpinned memory note: written, or why not (a later dismiss retries). */
242
- memory: "written" | { problem: string };
243
- }
244
-
245
- export interface FindingTicket {
246
- /** Which sink filed it — a close goes back through the same kind. */
247
- sink: string;
248
- /** The sink's reference: `FT-NNNN`, or the markdown file's path. */
249
- ref: string;
250
- effort: string;
251
- filed_at: string;
252
- /** Set once the ticket was closed because a re-scan found it fixed. */
253
- closed?: { at: string; sha: string; run: string } | null;
254
- }
255
-
256
- /** What a scan reports — a finding before the store gives it a history. */
257
- export type ReportedFinding = Omit<
258
- ScanFinding,
259
- | "state"
260
- | "first_seen"
261
- | "last_seen"
262
- | "seen"
263
- | "fixed"
264
- | "unreported"
265
- | "not_reproduced"
266
- | "aliases"
267
- | "lenses"
268
- | "merged"
269
- | "own_severity"
270
- | "own_judgement"
271
- | "dismissal"
272
- | "ticket"
273
- | "resembles_dismissed"
274
- >;
275
-
276
- // ---- near-duplicate collapse ----
277
-
278
- /** How far apart (lines between the two ranges) two sightings of one defect
279
- * may be anchored — one real pair sat 81 lines apart. */
280
- export const NEAR_LINES = 100;
281
- /** The stemmed-token Jaccard two claims must reach to be one defect. */
282
- export const CLAIM_SIMILARITY = 0.4;
283
-
284
- const STOPWORDS = new Set(
285
- (
286
- "the a an of to in on at is are be by for and or not than rather but its it this that with from as into instead " +
287
- "being has have was were can could may when which while every only their them then there these those does did do"
288
- ).split(" "),
289
- );
290
-
291
- /** A claim's content tokens, in order: lower-cased words of 3+ characters,
292
- * stopwords out, each stemmed to its first five characters (so
293
- * authorization/authority, validate/validation, refresh/refreshed agree;
294
- * words of five characters or fewer stay whole, so read/reads do not). */
295
- function claimStems(claim: string): string[] {
296
- return (claim.toLowerCase().match(/[a-z0-9]+/g) ?? [])
297
- .filter((w) => w.length >= 3 && !STOPWORDS.has(w))
298
- .map((w) => w.slice(0, 5));
299
- }
300
-
301
- /** Most severe first: `SEVERITIES`' own order. */
302
- const severityRank = (s: ScanSeverity): number => SEVERITIES.indexOf(s);
303
- const higher = (a: ScanSeverity, b: ScanSeverity): ScanSeverity =>
304
- severityRank(b) < severityRank(a) ? b : a;
305
-
306
- /**
307
- * Are `a` and `b` the same defect reported twice? Same file, same kind,
308
- * ranges overlapping or at most NEAR_LINES apart, the same subject (first
309
- * content stem — "Inline-comment reads…" and "Issue-comment reads…" are one
310
- * pattern in two places, not one defect) and claims whose stemmed tokens
311
- * overlap at CLAIM_SIMILARITY or more. Pure; symmetric.
312
- */
313
- export function nearDuplicate(
314
- a: Pick<ScanFinding, "path" | "kind" | "start" | "end" | "claim">,
315
- b: Pick<ScanFinding, "path" | "kind" | "start" | "end" | "claim">,
316
- ): boolean {
317
- if (a.path !== b.path || a.kind !== b.kind) return false;
318
- const gap = Math.max(a.start, b.start) - Math.min(a.end, b.end);
319
- if (gap > NEAR_LINES) return false;
320
- const sa = claimStems(a.claim);
321
- const sb = claimStems(b.claim);
322
- if (sa.length === 0 || sb.length === 0 || sa[0] !== sb[0]) return false;
323
- const A = new Set(sa);
324
- const B = new Set(sb);
325
- let common = 0;
326
- for (const t of A) if (B.has(t)) common++;
327
- return common / (A.size + B.size - common) >= CLAIM_SIMILARITY;
328
- }
329
-
330
- /**
331
- * The dismissed finding `r` resembles, and why it was dismissed — or null.
332
- * The dedup's own test (`nearDuplicate`) against every DISMISSED finding,
333
- * its own sighting and each near-duplicate collapsed into it, first in
334
- * store order. A new finding's wording can shift between runs, so an exact
335
- * fingerprint never catches a re-raise of what the operator already
336
- * dismissed; this says so. It decides nothing: the caller only flags.
337
- */
338
- function resemblingDismissal(
339
- stored: Iterable<ScanFinding>,
340
- r: Pick<ScanFinding, "fingerprint" | "path" | "kind" | "start" | "end" | "claim">,
341
- ): ResemblesDismissed | null {
342
- for (const d of stored) {
343
- if (d.state !== "dismissed" || d.fingerprint === r.fingerprint) continue;
344
- const sightings = [d, ...(d.merged ?? []).map((m) => ({ ...m, path: d.path, kind: d.kind }))];
345
- if (sightings.some((s) => nearDuplicate(s, r))) {
346
- return { fingerprint: d.fingerprint, reason: d.dismissal?.reason ?? "(no reason recorded)" };
347
- }
348
- }
349
- return null;
350
- }
351
-
352
- /** A validated survivor with no doubt, of the two, clears the flag. */
353
- function combinedDoubt(a: Judgement, b: Judgement): ScanFinding["doubt"] {
354
- if ((a.validated && a.doubt === null) || (b.validated && b.doubt === null)) return null;
355
- return a.doubt ?? b.doubt;
356
- }
357
-
358
- /** Better supported ranks higher — the display order (confirmed first,
359
- * unknown last) read backwards. */
360
- const validityRank = (v: ValidityView): number => VALIDITY_ORDER.length - VALIDITY_ORDER.indexOf(v);
361
-
362
- /** Two sightings of one defect: the best-supported tier wins (a confirmed
363
- * sighting outranks a doubt, as a clean survivor clears the doubt flag), and
364
- * its scenario with it. Unknown only when neither was judged. */
365
- function combinedValidity(a: Judgement, b: Judgement): Pick<ScanFinding, "validity" | "scenario"> {
366
- const best = validityRank(validityOf(b)) > validityRank(validityOf(a)) ? b : a;
367
- const v = validityOf(best);
368
- return { validity: v === "unknown" ? null : v, scenario: best.scenario ?? null };
369
- }
370
-
371
- /** The doubt and tier of two sightings, told as ONE story: when either was
372
- * tiered, the tier decides and the doubt follows it (a doubtful tier keeps
373
- * its doubt, a confirmed/likely one clears it) — so a finding never reads
374
- * doubtful with no reason, nor confirmed while flagged "may not be valid".
375
- * With no tier on either side, the pre-tier doubt rule stands. */
376
- function combinedJudgement(
377
- a: Judgement,
378
- b: Judgement,
379
- ): Pick<ScanFinding, "doubt" | "validity" | "scenario"> {
380
- const tiered = combinedValidity(a, b);
381
- if (tiered.validity === null || tiered.validity === undefined)
382
- return { doubt: combinedDoubt(a, b), ...tiered };
383
- if (tiered.validity !== "doubtful") return { doubt: null, ...tiered };
384
- // Neither side ranks above doubtful here, so at least one IS doubtful.
385
- const src = validityOf(b) === "doubtful" ? b : a;
386
- return { doubt: src.doubt ?? a.doubt ?? b.doubt, ...tiered };
387
- }
388
-
389
- /** A sighting nobody judged (the skeptic off or failed) says nothing about
390
- * the finding's validity: an exact re-sighting of the SAME code keeps the
391
- * judgement it already has rather than dropping a confirmed tier — or a
392
- * doubt — to unknown. Any judged sighting replaces it (the latest wins). */
393
- function judgementOf(known: Judgement, r: Judgement): Judgement {
394
- if (r.validated || validityOf(r) !== "unknown") {
395
- return {
396
- validated: r.validated,
397
- doubt: r.doubt,
398
- validity: r.validity ?? null,
399
- scenario: r.scenario ?? null,
400
- };
401
- }
402
- return {
403
- validated: known.validated,
404
- doubt: known.doubt,
405
- validity: known.validity ?? null,
406
- scenario: known.scenario ?? null,
407
- };
408
- }
409
-
410
- /** Can a fresh near-duplicate fold into `f`? Only a live finding this very
411
- * run has sighted — a sighting in a LATER run that merely resembles an old
412
- * one is the companion's match question, never a silent fold —
413
- * and never one the pending question holds. */
414
- function collapsible(f: ScanFinding, run: string, held: ReadonlySet<string>): boolean {
415
- return f.state !== "fixed" && f.last_seen.run === run && !held.has(f.fingerprint);
416
- }
417
-
418
- /** Fold stored finding `f` (fresh this run) into `into`, its own collapsed
419
- * sightings and aliases with it. `f` becomes a sighting at ITS OWN latest
420
- * severity — its aggregate already counts the sightings folded into it,
421
- * which come along as their own merged entries, so a later re-sighting of
422
- * one of them can still lower the whole. */
423
- function absorbFinding(into: ScanFinding, f: ScanFinding): ScanFinding {
424
- const once = absorb(into, { ...f, ...f.own_judgement, severity: f.own_severity ?? f.severity });
425
- const merged = [
426
- ...once.merged!,
427
- ...(f.merged ?? []).filter((m) => !once.merged!.some((x) => x.fingerprint === m.fingerprint)),
428
- ];
429
- return {
430
- ...once,
431
- severity: aggregateSeverity(once.own_severity!, merged),
432
- ...aggregateJudgements(once.own_judgement ?? judgementOf(once, once), merged),
433
- lenses: [...new Set([...once.lenses!, ...(f.lenses ?? [])])],
434
- merged,
435
- aliases: [...new Set([...once.aliases!, ...(f.aliases ?? [])])],
436
- };
437
- }
438
-
439
- /**
440
- * The end-of-run pass: a finding first seen this run, untriaged, that is a
441
- * near-duplicate of another finding this run sighted folds into it — what
442
- * report-time collapse misses when the duplicate's case ran before the
443
- * sighting it duplicates. Mutates `byFp`; returns how many folded.
444
- */
445
- function collapseRun(
446
- byFp: Map<string, ScanFinding>,
447
- run: string,
448
- held: ReadonlySet<string>,
449
- ): number {
450
- let folded = 0;
451
- const all = [...byFp.values()];
452
- for (const f of all) {
453
- if (!byFp.has(f.fingerprint)) continue;
454
- const fresh =
455
- f.state === "new" &&
456
- f.first_seen.run === run &&
457
- !f.ticket &&
458
- !f.dismissal &&
459
- !held.has(f.fingerprint);
460
- if (!fresh) continue;
461
- const into = [...byFp.values()].find(
462
- (o) => o.fingerprint !== f.fingerprint && collapsible(o, run, held) && nearDuplicate(o, f),
463
- );
464
- if (!into) continue;
465
- const cur = byFp.get(f.fingerprint)!;
466
- byFp.delete(f.fingerprint);
467
- byFp.set(into.fingerprint, absorbFinding(into, cur));
468
- folded++;
469
- }
470
- return folded;
471
- }
472
-
473
- function sightingOf(r: ReportedFinding): MergedSighting {
474
- return {
475
- fingerprint: r.fingerprint,
476
- lens: r.lens,
477
- start: r.start,
478
- end: r.end,
479
- severity: r.severity,
480
- claim: r.claim,
481
- validated: r.validated,
482
- doubt: r.doubt,
483
- validity: r.validity ?? null,
484
- scenario: r.scenario ?? null,
485
- };
486
- }
487
-
488
- function aggregateJudgements(own: Judgement, merged: readonly MergedSighting[]): Judgement {
489
- return merged.reduce((current, sighting) => {
490
- if (sighting.validated === undefined) return current;
491
- const next: Judgement = {
492
- validated: sighting.validated,
493
- doubt: sighting.doubt ?? null,
494
- validity: sighting.validity ?? null,
495
- scenario: sighting.scenario ?? null,
496
- };
497
- return { validated: current.validated || next.validated, ...combinedJudgement(current, next) };
498
- }, own);
499
- }
500
-
501
- /** The highest of a finding's own latest severity and each merged
502
- * sighting's latest. */
503
- function aggregateSeverity(own: ScanSeverity, merged: readonly MergedSighting[]): ScanSeverity {
504
- return merged.reduce((s, m) => higher(s, m.severity), own);
505
- }
506
-
507
- /** Fold reported near-duplicate `r` into stored `into`. It is a sighting at
508
- * the content `r` read, so the content hash moves with it. */
509
- function absorb(into: ScanFinding, r: ReportedFinding): ScanFinding {
510
- const lenses = [...new Set([...lensesOf(into), r.lens])];
511
- const merged = [
512
- ...(into.merged ?? []).filter((m) => m.fingerprint !== r.fingerprint),
513
- sightingOf({
514
- ...r,
515
- ...judgementOf(
516
- ((into.merged ?? []).find(
517
- (m) => m.fingerprint === r.fingerprint && m.validated !== undefined,
518
- ) as Judgement | undefined) ?? { validated: false, doubt: null },
519
- r,
520
- ),
521
- }),
522
- ];
523
- const ownJudgement = into.own_judgement ?? judgementOf(into, into);
524
- // Before anything collapsed into it, its severity IS its own; a record
525
- // stored before `own_severity` existed falls back to its aggregate.
526
- const own = into.own_severity ?? into.severity;
527
- return {
528
- ...into,
529
- ...(r.blob !== undefined ? { blob: r.blob } : {}),
530
- own_severity: own,
531
- severity: aggregateSeverity(own, merged),
532
- own_judgement: ownJudgement,
533
- ...aggregateJudgements(ownJudgement, merged),
534
- lenses,
535
- merged,
536
- aliases: [...new Set([...(into.aliases ?? []), r.fingerprint])],
537
- };
538
- }
539
-
540
- /** The companion's pending sameness call: stored findings a re-scan no
541
- * longer reports exactly, and the fresh findings any of them might be. */
542
- export interface MatchQuestion {
543
- /** Moves whenever the question does; an answer must quote it. */
544
- key: string;
545
- /** The latest run that fed the question — a finding left unpaired is
546
- * fixed as of this sighting. */
547
- run: string;
548
- sha: string;
549
- at: string;
550
- unreported: string[];
551
- fresh: string[];
552
- }
553
-
554
- export interface FindingsFile {
555
- version: number;
556
- findings: ScanFinding[];
557
- /** At most one pending match question. */
558
- question?: MatchQuestion | null;
559
- }
560
-
561
- export interface FindingsFilter {
562
- state?: FindingState;
563
- lens?: string;
564
- unit?: string;
565
- severity?: ScanSeverity;
566
- /** true: only "may not be valid"; false: only the rest. */
567
- doubted?: boolean;
568
- /** Only this tier (`unknown`: the findings no skeptic judged). */
569
- validity?: ValidityView;
570
- }
571
-
572
- export interface ReconcileResult {
573
- /** Findings fixed outright: nothing fresh could be them. */
574
- fixed: number;
575
- /** Unreported findings waiting on the companion's match. */
576
- pending: number;
577
- /** Open findings a re-read of their UNCHANGED content did not report
578
- * again — kept open, stamped `not_reproduced`, never fixed. */
579
- not_reproduced: number;
580
- /** Dismissed findings settled as no longer reported: they stay
581
- * `dismissed`, stamped `unreported` for audit — never counted as fixed. */
582
- dismissed_unreported: number;
583
- }
584
-
585
- export interface MatchPair {
586
- /** A fingerprint from the question's `unreported`. */
587
- old: string;
588
- /** A fingerprint from the question's `fresh`. */
589
- new: string;
590
- }
591
-
592
- export type MatchResult =
593
- | { ok: true; matched: number; fixed: number; new: number; dismissed_unreported: number }
594
- | { ok: false; errors: string[] };
595
-
596
- /** The question, both sides expanded, for the companion to judge. */
597
- export interface MatchQuestionView {
598
- key: string;
599
- run: string;
600
- sha: string;
601
- unreported: ScanFinding[];
602
- fresh: ScanFinding[];
603
- }
604
-
605
- /** Every lens that raised `f` — a collapsed finding's all of them. */
606
- const lensesOf = (f: ScanFinding): string[] => f.lenses ?? [f.lens];
607
-
608
- /** Do `a` and `b` share a lens that raised them? */
609
- function shareLens(a: ScanFinding, b: ScanFinding): boolean {
610
- const la = lensesOf(a);
611
- return lensesOf(b).some((l) => la.includes(l));
612
- }
613
-
614
- /** Could fresh finding `n` be old finding `o`, moved or reworded? A lens in
615
- * common (any lens a collapsed finding holds — its silent primary lens is
616
- * not the only one that can report it again), and the same unit or the
617
- * same kind. */
618
- function mightBe(o: ScanFinding, n: ScanFinding): boolean {
619
- return shareLens(o, n) && (o.unit === n.unit || o.kind === n.kind);
620
- }
621
-
622
- /** The state a `fixed` finding takes when a scan reports it again: promoted
623
- * stays promoted while its ticket is still open (nothing closed it yet);
624
- * anything else, including a promoted one whose ticket was closed, is `new`
625
- * again — the ticket record stays, so a re-promote never files twice.
626
- * (A dismissed finding is never fixed at all — `settleUnreported`.) */
627
- function returnedState(f: ScanFinding): FindingState {
628
- if (f.fixed?.from === "promoted" && f.ticket && !f.ticket.closed) return "promoted";
629
- return "new";
630
- }
631
-
632
- export { goneDismissal } from "./scanreport.js";
633
-
634
- /** Is `f` already settled — fixed, or a gone dismissal? A settled finding is
635
- * out of play until a scan reports it again. */
636
- export const settled = (f: ScanFinding): boolean => f.state === "fixed" || goneDismissal(f);
637
-
638
- /**
639
- * Settle a finding a re-scan no longer reports — the ONE place every path
640
- * (reconcile, a report that shrinks the question, the companion's match)
641
- * settles one. A DISMISSED finding stays dismissed (a re-scan never
642
- * overwrites the operator's triage), stamped `unreported` for audit;
643
- * any other becomes `fixed` (a promoted one's ticket then closes).
644
- * `null` when there was nothing to settle.
645
- */
646
- function settleUnreported(
647
- f: ScanFinding,
648
- at: Sighting,
649
- ): { finding: ScanFinding; as: "fixed" | "dismissed" } | null {
650
- if (f.state === "fixed" || settled(f)) return null;
651
- const where: Sighting = { run: at.run, sha: at.sha, at: at.at };
652
- if (f.state === "dismissed")
653
- return { finding: { ...f, unreported: where, not_reproduced: null }, as: "dismissed" };
654
- return {
655
- finding: {
656
- ...f,
657
- state: "fixed",
658
- fixed: { ...where, from: f.state },
659
- unreported: null,
660
- not_reproduced: null,
661
- },
662
- as: "fixed",
663
- };
664
- }
665
-
666
- /** The question over `unreported` × `fresh`, or null when nothing waits. */
667
- function questionOf(
668
- unreported: readonly string[],
669
- fresh: readonly string[],
670
- at: Sighting,
671
- ): MatchQuestion | null {
672
- if (unreported.length === 0) return null;
673
- const u = [...new Set(unreported)].sort();
674
- const f = [...new Set(fresh)].sort();
675
- const key = createHash("sha256")
676
- .update(JSON.stringify({ u, f, run: at.run }))
677
- .digest("hex")
678
- .slice(0, 16);
679
- return { key, run: at.run, sha: at.sha, at: at.at, unreported: u, fresh: f };
680
- }
681
-
682
- /**
683
- * Settle a question's two sides against `byFp`: an old finding with no
684
- * fresh candidate left is fixed now; a fresh one no old finding could be
685
- * leaves the question (it is simply new). Mutates `byFp`; returns the
686
- * question that still waits and how many it fixed.
687
- */
688
- function settleQuestion(
689
- byFp: Map<string, ScanFinding>,
690
- unreported: readonly string[],
691
- fresh: readonly string[],
692
- at: Sighting,
693
- ): { question: MatchQuestion | null; fixed: number; dismissed: number } {
694
- const live = (fp: string): ScanFinding | null => {
695
- const f = byFp.get(fp);
696
- return f && !settled(f) ? f : null;
697
- };
698
- const olds = [...new Set(unreported)].map(live).filter((f): f is ScanFinding => f !== null);
699
- const oldSet = new Set(olds.map((f) => f.fingerprint));
700
- const news = [...new Set(fresh)]
701
- .map(live)
702
- .filter((f): f is ScanFinding => f !== null && !oldSet.has(f.fingerprint));
703
- let fixed = 0;
704
- let dismissed = 0;
705
- const waiting: string[] = [];
706
- for (const o of olds) {
707
- if (news.some((n) => mightBe(o, n))) waiting.push(o.fingerprint);
708
- else {
709
- const s = settleUnreported(o, at)!;
710
- byFp.set(o.fingerprint, s.finding);
711
- if (s.as === "fixed") fixed++;
712
- else dismissed++;
713
- }
714
- }
715
- const held = olds.filter((o) => waiting.includes(o.fingerprint));
716
- const candidates = news.filter((n) => held.some((o) => mightBe(o, n))).map((n) => n.fingerprint);
717
- return { question: questionOf(waiting, candidates, at), fixed, dismissed };
718
- }
719
-
720
- /** The lines each lens raised `f` at: its own anchor for its own lens, and
721
- * each collapsed sighting's for that sighting's lens. A lens with no sighting
722
- * of its own (one a match carried over) answers for the finding's anchor. */
723
- function sightingsByLens(f: ScanFinding): Array<{ lens: string; start: number; end: number }> {
724
- const out = [{ lens: f.lens, start: f.start, end: f.end }];
725
- for (const m of f.merged ?? []) out.push({ lens: m.lens, start: m.start, end: m.end });
726
- for (const lens of lensesOf(f))
727
- if (!out.some((o) => o.lens === lens)) out.push({ lens, start: f.start, end: f.end });
728
- return out;
729
- }
730
-
731
- /**
732
- * The latest reading that re-read `f`'s lines at the very content it was
733
- * last seen at, by another run since, with EVERY lens that raised it — and
734
- * did not report it (a reading that did would have moved its last sighting).
735
- * Null when some lens has no such row. A ranged row counts only when its
736
- * range holds the lines THAT lens raised it at — a collapsed sighting's own
737
- * lines, not the finding's anchor.
738
- */
739
- function silentReread(
740
- f: ScanFinding,
741
- rows: readonly LedgerEntry[],
742
- current: string | undefined,
743
- ): LedgerEntry | null {
744
- if (f.blob == null || (current !== undefined && current !== f.blob)) return null;
745
- let latest: LedgerEntry | null = null;
746
- for (const s of sightingsByLens(f)) {
747
- let best: LedgerEntry | null = null;
748
- for (const r of rows) {
749
- if (r.lens !== s.lens || r.path !== f.path || r.blob !== f.blob) continue;
750
- if (r.run === f.last_seen.run || r.at < f.last_seen.at) continue;
751
- if (r.range && (r.range.start > s.start || r.range.end < s.end)) continue;
752
- if (best === null || r.at > best.at) best = r;
753
- }
754
- if (best === null) return null;
755
- if (latest === null || best.at > latest.at) latest = best;
756
- }
757
- return latest;
758
- }
759
-
760
- export class FindingsStore {
761
- constructor(private readonly state: StateFs) {}
762
-
763
- private loadFile(): { file: FindingsFile; error: string | null } {
764
- const empty: FindingsFile = { version: FINDINGS_VERSION, findings: [], question: null };
765
- let text: string | null;
766
- try {
767
- text = this.state.read(FINDINGS_FILE);
768
- } catch (err) {
769
- return {
770
- file: empty,
771
- error: `${FINDINGS_FILE} in ${this.state.location} cannot be read: ${String(err)}`,
772
- };
773
- }
774
- if (text === null) return { file: empty, error: null };
775
- try {
776
- return { file: parseFindingsFile(text), error: null };
777
- } catch (err) {
778
- return {
779
- file: empty,
780
- error:
781
- `${FINDINGS_FILE} in ${this.state.location} is not a findings store this version reads (${String(err)}) — ` +
782
- "nothing will overwrite it; move it aside to start over",
783
- };
784
- }
785
- }
786
-
787
- private write(findings: Iterable<ScanFinding>, question: MatchQuestion | null): void {
788
- const file: FindingsFile = { version: FINDINGS_VERSION, findings: [...findings], question };
789
- this.state.write(FINDINGS_FILE, `${JSON.stringify(file, null, 1)}\n`);
790
- }
791
-
792
- load(): { findings: ScanFinding[]; error: string | null } {
793
- const { file, error } = this.loadFile();
794
- return { findings: file.findings, error };
795
- }
796
-
797
- /**
798
- * Record what one case reported. Exact fingerprint match first: a known
799
- * finding keeps its state and first sighting; an unknown one is `new`.
800
- * A known finding the pending match question held as unreported has been
801
- * reported again, so it leaves the question. An unknown one that is a
802
- * near-duplicate of a stored live finding is collapsed into it
803
- * (`nearDuplicate`). Returns how many were new, how many matched (by
804
- * fingerprint or alias) and how many were collapsed. Throws when the stored file cannot be read — never
805
- * writes over it.
806
- */
807
- report(
808
- reported: readonly ReportedFinding[],
809
- seen: Sighting,
810
- ): { added: number; matched: number; merged: number } {
811
- const { file, error } = this.loadFile();
812
- if (error !== null) throw new Error(error);
813
- const byFp = new Map(file.findings.map((f) => [f.fingerprint, f]));
814
- // Every alias names the finding that carries it: an absorbed
815
- // near-duplicate's fingerprint, and a fingerprint the finding had before
816
- // the companion matched it to a moved or reworded sighting.
817
- const aliasOf = new Map<string, string>();
818
- for (const f of file.findings) {
819
- for (const a of f.aliases ?? []) aliasOf.set(a, f.fingerprint);
820
- for (const a of f.merged ?? []) aliasOf.set(a.fingerprint, f.fingerprint);
821
- }
822
- const held = new Set(file.question?.unreported ?? []);
823
- let added = 0;
824
- let matched = 0;
825
- let merged = 0;
826
- const back = new Set<string>();
827
- for (const r of reported) {
828
- const ownFp = byFp.has(r.fingerprint) ? r.fingerprint : aliasOf.get(r.fingerprint);
829
- const known = ownFp === undefined ? undefined : byFp.get(ownFp);
830
- if (known && ownFp !== undefined) {
831
- // The same sighting twice (one case reporting a duplicate) counts once.
832
- const again = known.last_seen.run === seen.run && known.last_seen.sha === seen.sha;
833
- const state: FindingState = known.state === "fixed" ? returnedState(known) : known.state;
834
- const history = {
835
- state,
836
- fixed: null,
837
- unreported: null,
838
- not_reproduced: null,
839
- first_seen: known.first_seen,
840
- last_seen: seen,
841
- seen: again ? known.seen : known.seen + 1,
842
- };
843
- if (!(known.merged ?? []).some((m) => m.fingerprint === r.fingerprint)) {
844
- // Its own fingerprint, or one it carried before a match: a sighting
845
- // of the finding itself, stored under the identity it already has.
846
- // A collapsed finding carries the highest severity its sightings now
847
- // give — its own latest and each absorbed one's latest, never a
848
- // stale maximum a re-scan has since lowered.
849
- const severity = aggregateSeverity(r.severity, known.merged ?? []);
850
- const ownJudgement = judgementOf(known.own_judgement ?? known, r);
851
- byFp.set(ownFp, {
852
- ...known,
853
- ...r,
854
- fingerprint: ownFp,
855
- ...aggregateJudgements(ownJudgement, known.merged ?? []),
856
- severity,
857
- ...(known.merged ? { own_severity: r.severity, own_judgement: ownJudgement } : {}),
858
- ...history,
859
- });
860
- } else {
861
- // A sighting of an absorbed near-duplicate: the finding keeps its own
862
- // wording and position; the alias's record and the severity move.
863
- byFp.set(ownFp, { ...absorb(known, r), ...history });
864
- }
865
- // An untriaged finding's resemblance is re-read at every sighting: a
866
- // dismissal made (or undone) since is reflected, never a stale flag.
867
- const now = byFp.get(ownFp)!;
868
- if (now.state === "new") {
869
- const { resembles_dismissed: _was, ...rest } = now;
870
- const lookalike = resemblingDismissal(byFp.values(), now);
871
- byFp.set(ownFp, lookalike ? { ...rest, resembles_dismissed: lookalike } : rest);
872
- }
873
- back.add(ownFp);
874
- matched++;
875
- continue;
876
- }
877
- const twin = [...byFp.values()].find(
878
- (f) => collapsible(f, seen.run, held) && nearDuplicate(f, r),
879
- );
880
- if (twin) {
881
- byFp.set(twin.fingerprint, absorb(twin, r));
882
- aliasOf.set(r.fingerprint, twin.fingerprint);
883
- merged++;
884
- } else {
885
- const lookalike = resemblingDismissal(byFp.values(), r);
886
- byFp.set(r.fingerprint, {
887
- ...r,
888
- state: "new",
889
- first_seen: seen,
890
- last_seen: seen,
891
- seen: 1,
892
- ...(lookalike ? { resembles_dismissed: lookalike } : {}),
893
- });
894
- added++;
895
- }
896
- }
897
- let question = file.question ?? null;
898
- if (question !== null && question.unreported.some((fp) => back.has(fp))) {
899
- const q = question;
900
- question = settleQuestion(
901
- byFp,
902
- q.unreported.filter((fp) => !back.has(fp)),
903
- q.fresh,
904
- { run: q.run, sha: q.sha, at: q.at },
905
- ).question;
906
- }
907
- this.write(byFp.values(), question);
908
- return { added, matched, merged };
909
- }
910
-
911
- /**
912
- * After a run: find the stored findings a re-scan no longer reports and
913
- * settle what an exact rule can. A finding is UNREPORTED when its file is
914
- * gone from the run's tree, or when the ledger's newest row for its lens
915
- * and file is a later run's reading of other content — so a run the
916
- * companion never finished is still counted by the next one. Unreported
917
- * findings with no possible fresh counterpart are fixed now; the rest join
918
- * the one pending match question, with the fresh findings (first seen by
919
- * the runs that re-read those files) that might be them.
920
- */
921
- reconcile(args: {
922
- rows: readonly LedgerEntry[];
923
- tree: ReadonlySet<string>;
924
- /** The run's tree's content hash per path: when given, only a reading
925
- * of THAT content settles a finding — a newer reading of other content
926
- * says nothing about a tree that has since gone back. */
927
- blobs?: ReadonlyMap<string, string>;
928
- run: Sighting;
929
- }): ReconcileResult {
930
- const { file, error } = this.loadFile();
931
- if (error !== null) throw new Error(error);
932
- const byFp = new Map(file.findings.map((f) => [f.fingerprint, f]));
933
- // Each file's newest reading per lens — only a COMPLETE one: a file read
934
- // in line ranges whose ranges are not all back yet has not been re-read,
935
- // and the findings in its unread ranges must not read as fixed.
936
- const newest = new Map<string, Reading>();
937
- for (const lens of new Set(args.rows.map((r) => r.lens))) {
938
- for (const [path, r] of readings(args.rows, lens))
939
- if (r.complete) newest.set(`${lens}\0${path}`, r);
940
- }
941
- const prior = file.question ?? null;
942
- collapseRun(
943
- byFp,
944
- args.run.run,
945
- new Set([...(prior?.unreported ?? []), ...(prior?.fresh ?? [])]),
946
- );
947
- const pending = new Set(
948
- (prior?.unreported ?? []).filter((fp) => {
949
- const finding = byFp.get(fp);
950
- const current = finding ? args.blobs?.get(finding.path) : undefined;
951
- return current === undefined || finding?.blob == null || current !== finding.blob;
952
- }),
953
- );
954
- const rereadBy = new Set<string>([args.run.run]);
955
- const unreported: string[] = [];
956
- let notReproduced = 0;
957
- for (const f of [...byFp.values()]) {
958
- if (settled(f) || pending.has(f.fingerprint)) continue;
959
- if (!args.tree.has(f.path)) {
960
- unreported.push(f.fingerprint);
961
- // Wherever it moved may have been read by a run the companion never
962
- // finished: every run of its lenses since its last sighting is one
963
- // whose fresh findings might be it.
964
- const lenses = lensesOf(f);
965
- for (const r of args.rows) {
966
- if (r.run !== f.last_seen.run && r.at >= f.last_seen.at && lenses.includes(r.lens))
967
- rereadBy.add(r.run);
968
- }
969
- continue;
970
- }
971
- const current = args.blobs?.get(f.path);
972
- // A collapsed finding is unreported only when EVERY lens that raised
973
- // it has re-read other content since — one lens's silence is not the
974
- // other's, and a single-lens run must not settle what it never read.
975
- const readingsOf = lensesOf(f).map((l) => newest.get(`${l}\0${f.path}`));
976
- const reread = readingsOf.every((reading) => {
977
- const row = reading?.newest;
978
- return (
979
- row !== undefined &&
980
- row.run !== f.last_seen.run &&
981
- row.at >= f.last_seen.at &&
982
- !(f.blob != null && row.blob === f.blob) &&
983
- (current === undefined || row.blob === current)
984
- );
985
- });
986
- if (!reread) {
987
- // Only OTHER content can have fixed it: a later reading of
988
- // the content it was last seen at, over its lines, that did not
989
- // report it again is not a fix — it stays open, stamped, once per
990
- // reading. A dismissed one is already triaged: nothing to stamp or
991
- // count (the count asks for triage).
992
- const silent = f.state === "dismissed" ? null : silentReread(f, args.rows, current);
993
- if (silent !== null && !(f.not_reproduced && f.not_reproduced.at >= silent.at)) {
994
- byFp.set(f.fingerprint, {
995
- ...f,
996
- not_reproduced: {
997
- run: silent.run,
998
- sha: silent.sha,
999
- at: silent.at,
1000
- count: (f.not_reproduced?.count ?? 0) + 1,
1001
- },
1002
- });
1003
- notReproduced++;
1004
- }
1005
- continue;
1006
- }
1007
- unreported.push(f.fingerprint);
1008
- // Every run of each reading re-read the file: a ranged reading finished
1009
- // by a later run keeps the earlier run's findings as candidates.
1010
- for (const reading of readingsOf) for (const r of reading!.runs) rereadBy.add(r);
1011
- }
1012
- const fresh = [...byFp.values()]
1013
- .filter((f) => f.state !== "fixed" && rereadBy.has(f.first_seen.run))
1014
- .map((f) => f.fingerprint);
1015
- const at: Sighting = args.run;
1016
- const settledNow = settleQuestion(
1017
- byFp,
1018
- [...pending, ...unreported],
1019
- [...(prior?.fresh ?? []), ...fresh],
1020
- at,
1021
- );
1022
- // A run that changed nothing about the question leaves it as it was: its
1023
- // key must not move under a companion that is judging it right now.
1024
- const same = (a: readonly string[], b: readonly string[]): boolean =>
1025
- a.length === b.length && a.every((x, i) => x === b[i]);
1026
- const question =
1027
- prior !== null &&
1028
- settledNow.question !== null &&
1029
- same(settledNow.question.unreported, prior.unreported) &&
1030
- same(settledNow.question.fresh, prior.fresh)
1031
- ? prior
1032
- : settledNow.question;
1033
- this.write(byFp.values(), question);
1034
- return {
1035
- fixed: settledNow.fixed,
1036
- pending: settledNow.question?.unreported.length ?? 0,
1037
- not_reproduced: notReproduced,
1038
- dismissed_unreported: settledNow.dismissed,
1039
- };
1040
- }
1041
-
1042
- /** The pending match question with both sides expanded, or null. */
1043
- question(): { question: MatchQuestionView | null; error: string | null } {
1044
- const { file, error } = this.loadFile();
1045
- const q = file.question ?? null;
1046
- if (q === null) return { question: null, error };
1047
- const byFp = new Map(file.findings.map((f) => [f.fingerprint, f]));
1048
- const expand = (fps: readonly string[]): ScanFinding[] =>
1049
- fps.map((fp) => byFp.get(fp)).filter((f): f is ScanFinding => f !== undefined);
1050
- return {
1051
- question: {
1052
- key: q.key,
1053
- run: q.run,
1054
- sha: q.sha,
1055
- unreported: expand(q.unreported),
1056
- fresh: expand(q.fresh),
1057
- },
1058
- error,
1059
- };
1060
- }
1061
-
1062
- /**
1063
- * The companion's answer to the pending question, whole: `pairs` are the
1064
- * same finding (one-to-one, a lens in common); every unreported finding it does
1065
- * not pair is fixed; every fresh one it does not pair stays new. Refused —
1066
- * all of it — on a stale key, an unknown fingerprint, a reused one, a
1067
- * lens mismatch, or a pair whose two sides carry different tickets.
1068
- */
1069
- match(args: { key?: unknown; pairs?: unknown }): MatchResult {
1070
- const { file, error } = this.loadFile();
1071
- if (error !== null) return { ok: false, errors: [error] };
1072
- const q = file.question ?? null;
1073
- if (q === null)
1074
- return { ok: false, errors: ["no match question is pending — nothing to judge"] };
1075
- if (args.key !== q.key) {
1076
- return {
1077
- ok: false,
1078
- errors: [
1079
- `the question moved since you read it (key ${String(args.key)}, now ${q.key}) — read it again`,
1080
- ],
1081
- };
1082
- }
1083
- if (args.pairs !== undefined && !Array.isArray(args.pairs))
1084
- return { ok: false, errors: ["`pairs` must be a list of {old, new}"] };
1085
- const byFp = new Map(file.findings.map((f) => [f.fingerprint, f]));
1086
- const pairs: MatchPair[] = [];
1087
- const errors: string[] = [];
1088
- const usedOld = new Set<string>();
1089
- const usedNew = new Set<string>();
1090
- for (const raw of (args.pairs ?? []) as unknown[]) {
1091
- const p = raw as Partial<MatchPair> | null;
1092
- const o = typeof p?.old === "string" ? p.old : "";
1093
- const n = typeof p?.new === "string" ? p.new : "";
1094
- if (!q.unreported.includes(o))
1095
- errors.push(`${JSON.stringify(o)} is not an unreported finding of this question`);
1096
- else if (usedOld.has(o)) errors.push(`${o.slice(0, 12)} is paired twice`);
1097
- if (!q.fresh.includes(n))
1098
- errors.push(`${JSON.stringify(n)} is not a fresh finding of this question`);
1099
- else if (usedNew.has(n)) errors.push(`${n.slice(0, 12)} is paired twice`);
1100
- const of = byFp.get(o);
1101
- const nf = byFp.get(n);
1102
- if (of && nf && !shareLens(of, nf))
1103
- errors.push(`${o.slice(0, 12)} and ${n.slice(0, 12)} are different lenses' findings`);
1104
- // Each side's ticket is a filed work item: a pair that would keep only
1105
- // one of two different tickets would orphan the other.
1106
- else if (of?.ticket && nf?.ticket && of.ticket.ref !== nf.ticket.ref) {
1107
- errors.push(
1108
- `${o.slice(0, 12)} (${of.ticket.ref}) and ${n.slice(0, 12)} (${nf.ticket.ref}) each carry their own ticket — pairing would orphan one; leave them unpaired (the old one then settles as fixed and its ticket closes, the fresh one keeps its own)`,
1109
- );
1110
- }
1111
- usedOld.add(o);
1112
- usedNew.add(n);
1113
- pairs.push({ old: o, new: n });
1114
- }
1115
- if (errors.length > 0) return { ok: false, errors };
1116
-
1117
- const at: Sighting = { run: q.run, sha: q.sha, at: q.at };
1118
- let matched = 0;
1119
- for (const { old, new: nfp } of pairs) {
1120
- const o = byFp.get(old);
1121
- const n = byFp.get(nfp);
1122
- if (!o || !n) continue;
1123
- byFp.delete(old);
1124
- // The triage travels with the finding: a matched promoted finding
1125
- // keeps its ticket (never filed twice), a dismissed one its reason —
1126
- // and a fresh one promoted or dismissed while the question waited
1127
- // keeps its own when the old side has none.
1128
- const triaged = o.ticket ? o : n.ticket ? n : o.dismissal ? o : n.dismissal ? n : o;
1129
- // The sightings each side had collapsed come along, and the severity is
1130
- // the highest of the fresh finding's own and theirs — the invariant a
1131
- // report keeps, never the fresh record's alone.
1132
- const merged = o.merged || n.merged ? [...(o.merged ?? []), ...(n.merged ?? [])] : null;
1133
- const own = n.own_severity ?? n.severity;
1134
- const ownJudgement = n.own_judgement ?? judgementOf(n, n);
1135
- byFp.set(nfp, {
1136
- ...n,
1137
- ...(merged
1138
- ? {
1139
- own_severity: own,
1140
- severity: aggregateSeverity(own, merged),
1141
- own_judgement: ownJudgement,
1142
- ...aggregateJudgements(ownJudgement, merged),
1143
- }
1144
- : {}),
1145
- state: triaged.state,
1146
- fixed: null,
1147
- first_seen: o.first_seen,
1148
- seen: o.seen + n.seen,
1149
- aliases: [...new Set([...(o.aliases ?? []), old, ...(n.aliases ?? [])])],
1150
- ...(o.lenses || n.lenses ? { lenses: [...new Set([...lensesOf(o), ...lensesOf(n)])] } : {}),
1151
- ...(merged ? { merged } : {}),
1152
- dismissal: o.dismissal ?? n.dismissal ?? null,
1153
- ticket: triaged.ticket ?? null,
1154
- });
1155
- matched++;
1156
- }
1157
- let fixed = 0;
1158
- let dismissed = 0;
1159
- for (const fp of q.unreported) {
1160
- if (usedOld.has(fp)) continue;
1161
- const f = byFp.get(fp);
1162
- const s = f ? settleUnreported(f, at) : null;
1163
- if (s) {
1164
- byFp.set(fp, s.finding);
1165
- if (s.as === "fixed") fixed++;
1166
- else dismissed++;
1167
- }
1168
- }
1169
- this.write(byFp.values(), null);
1170
- return {
1171
- ok: true,
1172
- matched,
1173
- fixed,
1174
- new: q.fresh.filter((fp) => !usedNew.has(fp)).length,
1175
- dismissed_unreported: dismissed,
1176
- };
1177
- }
1178
-
1179
- /** The stored findings the filter keeps, most severe first — a finding
1180
- * flagged as resembling a dismissed one below the rest of its severity —
1181
- * then by file. */
1182
- list(filter: FindingsFilter = {}): { findings: ScanFinding[]; error: string | null } {
1183
- const { findings, error } = this.load();
1184
- const rank = severityRank;
1185
- const kept = findings
1186
- .filter(
1187
- (f) =>
1188
- (filter.state === undefined || f.state === filter.state) &&
1189
- (filter.lens === undefined ||
1190
- f.lens === filter.lens ||
1191
- (f.lenses ?? []).includes(filter.lens)) &&
1192
- (filter.unit === undefined || f.unit === filter.unit) &&
1193
- (filter.severity === undefined || f.severity === filter.severity) &&
1194
- (filter.doubted === undefined || (f.doubt !== null) === filter.doubted) &&
1195
- (filter.validity === undefined || validityOf(f) === filter.validity),
1196
- )
1197
- .sort(
1198
- (a, b) =>
1199
- rank(a.severity) - rank(b.severity) ||
1200
- lookalikeRank(a) - lookalikeRank(b) ||
1201
- (a.path < b.path ? -1 : a.path > b.path ? 1 : a.start - b.start),
1202
- );
1203
- return { findings: kept, error };
1204
- }
1205
-
1206
- // -- triage (the rules are triage.ts's, the record is here) --
1207
-
1208
- /**
1209
- * The one stored finding `ref` names: a full fingerprint, an alias (a
1210
- * fingerprint it carried before a match), or an unambiguous prefix of at
1211
- * least 12 hex characters — what `scan_findings` shows.
1212
- */
1213
- find(ref: string): { ok: true; finding: ScanFinding } | { ok: false; errors: string[] } {
1214
- const { findings, error } = this.load();
1215
- if (error !== null) return { ok: false, errors: [error] };
1216
- const want = ref.trim().toLowerCase();
1217
- if (!/^[0-9a-f]{12,64}$/.test(want))
1218
- return {
1219
- ok: false,
1220
- errors: [`${JSON.stringify(ref)} is not a fingerprint (12+ hex characters)`],
1221
- };
1222
- const exact = findings.find((f) => f.fingerprint === want || (f.aliases ?? []).includes(want));
1223
- if (exact) return { ok: true, finding: exact };
1224
- const hits = findings.filter(
1225
- (f) => f.fingerprint.startsWith(want) || (f.aliases ?? []).some((a) => a.startsWith(want)),
1226
- );
1227
- if (hits.length === 1) return { ok: true, finding: hits[0]! };
1228
- return {
1229
- ok: false,
1230
- errors: [
1231
- hits.length === 0
1232
- ? `no stored finding has fingerprint ${want}`
1233
- : `${want} matches ${hits.length} findings — give more of the fingerprint`,
1234
- ],
1235
- };
1236
- }
1237
-
1238
- /**
1239
- * Rewrite ONE finding, by its current fingerprint, under a fresh load —
1240
- * refused (nothing written) when the store cannot be read or the finding
1241
- * is gone (a match renamed it since the caller looked). `change` returns
1242
- * the new record, or an error string to refuse.
1243
- */
1244
- update(
1245
- fingerprint: string,
1246
- change: (f: ScanFinding) => ScanFinding | string,
1247
- ): { ok: true; finding: ScanFinding } | { ok: false; errors: string[] } {
1248
- const { file, error } = this.loadFile();
1249
- if (error !== null) return { ok: false, errors: [error] };
1250
- const i = file.findings.findIndex((f) => f.fingerprint === fingerprint);
1251
- if (i < 0)
1252
- return {
1253
- ok: false,
1254
- errors: [
1255
- `finding ${fingerprint.slice(0, 12)} is no longer stored under that fingerprint — list the findings again`,
1256
- ],
1257
- };
1258
- const next = change(file.findings[i]!);
1259
- if (typeof next === "string") return { ok: false, errors: [next] };
1260
- file.findings[i] = next;
1261
- this.write(file.findings, file.question ?? null);
1262
- return { ok: true, finding: next };
1263
- }
1264
- }
1
+ // Moved to the plugin (packages/pi-plugin/src/scan/findings.ts), the scan engine's
2
+ // home: this name stays for the app's importers.
3
+ export * from "@quickchatai/pi-quickreview/src/scan/findings.js";