@reticlehq/server 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/LICENSE-ENTERPRISE +1 -1
  2. package/README.md +3 -3
  3. package/SKILL.md +12 -14
  4. package/dist/chromium-launch-options.d.ts +4 -3
  5. package/dist/chromium-launch-options.js +6 -2
  6. package/dist/command/cli/adhoc-suite.d.ts +22 -0
  7. package/dist/command/cli/adhoc-suite.js +90 -19
  8. package/dist/command/cli/adhoc-verdict.d.ts +35 -0
  9. package/dist/command/cli/adhoc-verdict.js +90 -3
  10. package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
  11. package/dist/command/cli/answers/gate-hook-message.js +6 -0
  12. package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
  13. package/dist/command/cli/auth/cloud-keystore.js +0 -3
  14. package/dist/command/cli/auth/cloud-session.d.ts +6 -2
  15. package/dist/command/cli/auth/cloud-session.js +2 -0
  16. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  17. package/dist/command/cli/cli-doctor.js +7 -5
  18. package/dist/command/cli/cli-flow-commands.d.ts +3 -6
  19. package/dist/command/cli/cli-flow-commands.js +67 -4
  20. package/dist/command/cli/cli-parse.d.ts +8 -1
  21. package/dist/command/cli/cli-parse.js +24 -4
  22. package/dist/command/cli/cli-update-commands.js +8 -1
  23. package/dist/command/cli/cli-verify.d.ts +24 -3
  24. package/dist/command/cli/cli-verify.js +54 -11
  25. package/dist/command/cli/cloud-cli.js +64 -2
  26. package/dist/command/cli/cloud-kit.js +3 -5
  27. package/dist/command/cli/cloud-login.d.ts +1 -1
  28. package/dist/command/cli/cloud-login.js +27 -8
  29. package/dist/command/cli/demo/demo-page.js +6 -3
  30. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
  31. package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
  32. package/dist/command/cli/launch/cli-launch.d.ts +8 -0
  33. package/dist/command/cli/launch/cli-launch.js +21 -9
  34. package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
  35. package/dist/command/cli/launch/sdk-version-match.js +206 -0
  36. package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
  37. package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
  38. package/dist/command/cli/ports/resolve/cli-port.js +125 -15
  39. package/dist/command/cli/setup-mcp-cli.js +21 -13
  40. package/dist/command/cli/tutorial.d.ts +2 -2
  41. package/dist/command/cli/tutorial.js +21 -7
  42. package/dist/command/cli.js +79 -11
  43. package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
  44. package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
  45. package/dist/command/daemon/daemon-resolve.d.ts +9 -0
  46. package/dist/command/daemon/daemon-resolve.js +1 -1
  47. package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
  48. package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
  49. package/dist/command/dev/stale-issue-guard.d.ts +3 -4
  50. package/dist/command/dev/stale-issue-guard.js +8 -8
  51. package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
  52. package/dist/command/setup/bringup/connect-lease.js +38 -0
  53. package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
  54. package/dist/command/setup/bringup/dev-server-files.js +131 -0
  55. package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
  56. package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
  57. package/dist/command/setup/bringup/dev-server-wait.d.ts +2 -0
  58. package/dist/command/setup/bringup/dev-server-wait.js +1 -1
  59. package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
  60. package/dist/command/setup/bringup/ensure-daemon.js +135 -16
  61. package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
  62. package/dist/command/setup/bringup/static-page-server.js +151 -0
  63. package/dist/command/setup/desktop-shape.d.ts +4 -0
  64. package/dist/command/setup/desktop-shape.js +18 -2
  65. package/dist/command/setup/init/init-port.d.ts +15 -0
  66. package/dist/command/setup/init/init-port.js +44 -0
  67. package/dist/command/setup/init/init-runtime.d.ts +1 -1
  68. package/dist/command/setup/init/init-runtime.js +91 -9
  69. package/dist/command/setup/node-effects.d.ts +27 -14
  70. package/dist/command/setup/node-effects.js +86 -45
  71. package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
  72. package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
  73. package/dist/command/setup/probe/page-probe.d.ts +13 -2
  74. package/dist/command/setup/probe/page-probe.js +18 -3
  75. package/dist/command/setup/run-setup.d.ts +28 -1
  76. package/dist/command/setup/run-setup.js +103 -15
  77. package/dist/command/setup/session-pick.d.ts +13 -0
  78. package/dist/command/setup/session-pick.js +20 -0
  79. package/dist/command/setup/setup-command.d.ts +2 -14
  80. package/dist/command/setup/setup-command.js +105 -31
  81. package/dist/command/setup/setup-mcp.d.ts +2 -0
  82. package/dist/command/setup/setup-mcp.js +17 -8
  83. package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
  84. package/dist/command/setup/terminal/wait-progress.js +17 -0
  85. package/dist/command/version/peer-announce.d.ts +6 -2
  86. package/dist/command/version/peer-announce.js +8 -6
  87. package/dist/command/version/version-nudge.d.ts +17 -3
  88. package/dist/command/version/version-nudge.js +25 -4
  89. package/dist/features/crawl/crawl-empty.js +1 -1
  90. package/dist/features/crawl/crawl-tools.d.ts +6 -0
  91. package/dist/features/crawl/crawl-tools.js +57 -0
  92. package/dist/features/crawl/crawl.js +171 -157
  93. package/dist/features/exhaust/app-coverage.d.ts +33 -0
  94. package/dist/features/exhaust/app-coverage.js +31 -0
  95. package/dist/features/exhaust/code-coverage.d.ts +47 -0
  96. package/dist/features/exhaust/code-coverage.js +77 -0
  97. package/dist/features/exhaust/exhaust-run.d.ts +33 -0
  98. package/dist/features/exhaust/exhaust-run.js +82 -0
  99. package/dist/features/exhaust/explorer.d.ts +103 -0
  100. package/dist/features/exhaust/explorer.js +195 -0
  101. package/dist/features/exhaust/failure-branches.d.ts +33 -0
  102. package/dist/features/exhaust/failure-branches.js +92 -0
  103. package/dist/features/exhaust/ledger.d.ts +202 -0
  104. package/dist/features/exhaust/ledger.js +252 -0
  105. package/dist/features/exhaust/session-fold.d.ts +31 -0
  106. package/dist/features/exhaust/session-fold.js +79 -0
  107. package/dist/features/exhaust/session-port.d.ts +36 -0
  108. package/dist/features/exhaust/session-port.js +102 -0
  109. package/dist/features/harness/drive-report.d.ts +0 -9
  110. package/dist/features/harness/drive-report.js +18 -0
  111. package/dist/features/harness/driver.js +4 -7
  112. package/dist/features/harness/fill-values.d.ts +10 -0
  113. package/dist/features/harness/fill-values.js +50 -11
  114. package/dist/features/harness/jev-driver.js +30 -13
  115. package/dist/features/harness/openai-driver.d.ts +12 -0
  116. package/dist/features/harness/openai-driver.js +21 -12
  117. package/dist/features/harness/platform-config.js +5 -6
  118. package/dist/features/license/license.d.ts +2 -2
  119. package/dist/index.d.ts +9 -0
  120. package/dist/index.js +56 -19
  121. package/dist/judgement/capsule/capsule-store.d.ts +4 -4
  122. package/dist/judgement/domain/domain-model.js +3 -1
  123. package/dist/judgement/oracles/self-instrument.js +4 -2
  124. package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
  125. package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
  126. package/dist/judgement/runs/verification-sync.d.ts +3 -1
  127. package/dist/judgement/runs/verification-sync.js +1 -1
  128. package/dist/language/flows/change/affected.d.ts +9 -0
  129. package/dist/language/flows/change/affected.js +35 -0
  130. package/dist/language/flows/change/gate.d.ts +20 -0
  131. package/dist/language/flows/change/gate.js +12 -1
  132. package/dist/language/flows/flow-author.d.ts +7 -0
  133. package/dist/language/flows/flow-author.js +19 -0
  134. package/dist/language/flows/flow-expect-grammar.js +1 -1
  135. package/dist/language/flows/flow-journey.d.ts +54 -0
  136. package/dist/language/flows/flow-journey.js +119 -0
  137. package/dist/language/flows/flow-replay-run.d.ts +2 -3
  138. package/dist/language/flows/flow-replay-run.js +13 -47
  139. package/dist/language/flows/flow-replay.d.ts +22 -8
  140. package/dist/language/flows/flow-replay.js +55 -9
  141. package/dist/language/flows/flow-result.d.ts +2 -0
  142. package/dist/language/flows/flow-result.js +25 -0
  143. package/dist/language/flows/flow-tools.js +17 -5
  144. package/dist/language/flows/flows.d.ts +10 -1
  145. package/dist/language/flows/flows.js +90 -14
  146. package/dist/language/flows/recording/replay-halt.js +3 -1
  147. package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
  148. package/dist/language/flows/recording/tape/recordings.js +55 -1
  149. package/dist/language/flows/replay.d.ts +19 -0
  150. package/dist/language/flows/replay.js +42 -6
  151. package/dist/language/flows/verify-change-tools.js +5 -2
  152. package/dist/launch-chromium.d.ts +80 -0
  153. package/dist/launch-chromium.js +218 -0
  154. package/dist/memory/cloud/cloud-config.d.ts +11 -2
  155. package/dist/memory/cloud/cloud-config.js +47 -7
  156. package/dist/memory/cloud/cloud-sync.d.ts +4 -1
  157. package/dist/memory/cloud/cloud-sync.js +6 -9
  158. package/dist/memory/cloud/harness-offer.js +5 -6
  159. package/dist/memory/cloud/harness-switch.js +5 -6
  160. package/dist/memory/cloud/sync-cycle.d.ts +77 -3
  161. package/dist/memory/cloud/sync-cycle.js +325 -55
  162. package/dist/memory/cloud/sync-daemon.js +24 -20
  163. package/dist/memory/cloud/sync-disk.js +16 -2
  164. package/dist/memory/intent/intent-shard.d.ts +10 -32
  165. package/dist/memory/intent/intent-shard.js +0 -10
  166. package/dist/memory/intent/intent-tools.js +8 -3
  167. package/dist/memory/journal/drive-flow.d.ts +18 -13
  168. package/dist/memory/journal/drive-flow.js +45 -37
  169. package/dist/memory/journal/envelope.d.ts +8 -8
  170. package/dist/memory/journal/journal-query.d.ts +14 -0
  171. package/dist/memory/journal/journal-query.js +15 -0
  172. package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
  173. package/dist/memory/journal/session-end.d.ts +9 -1
  174. package/dist/memory/journal/session-end.js +24 -12
  175. package/dist/memory/project/artifact-root-resolver.d.ts +10 -0
  176. package/dist/memory/project/artifact-root-resolver.js +46 -19
  177. package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
  178. package/dist/memory/project/dir/reticle-dir.js +1 -0
  179. package/dist/memory/project/project-tools.js +3 -1
  180. package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
  181. package/dist/portal/bridge/auth-failure-reason.js +4 -2
  182. package/dist/portal/bridge/bridge.d.ts +12 -3
  183. package/dist/portal/bridge/bridge.js +49 -3
  184. package/dist/portal/input/js-coverage.d.ts +25 -0
  185. package/dist/portal/input/js-coverage.js +30 -0
  186. package/dist/portal/input/real-input.d.ts +11 -0
  187. package/dist/portal/input/real-input.js +29 -4
  188. package/dist/portal/input/viewport-tools.js +16 -6
  189. package/dist/portal/pool/playwright-launcher.d.ts +6 -0
  190. package/dist/portal/pool/playwright-launcher.js +20 -13
  191. package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
  192. package/dist/portal/session/dev-server/dev-command.js +30 -12
  193. package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
  194. package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
  195. package/dist/portal/session/dev-server/route-status-probe.js +57 -0
  196. package/dist/portal/session/facts/observed-state.d.ts +11 -0
  197. package/dist/portal/session/facts/observed-state.js +34 -0
  198. package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
  199. package/dist/portal/session/no-session-diagnosis.js +62 -5
  200. package/dist/portal/session/no-session-next-action.d.ts +13 -0
  201. package/dist/portal/session/no-session-next-action.js +48 -9
  202. package/dist/portal/session/no-session-watch.d.ts +18 -0
  203. package/dist/portal/session/no-session-watch.js +88 -2
  204. package/dist/portal/session/session-health.js +3 -2
  205. package/dist/portal/session/session-manager.d.ts +20 -6
  206. package/dist/portal/session/session-manager.js +19 -3
  207. package/dist/portal/session/session-reaper.d.ts +28 -0
  208. package/dist/portal/session/session-reaper.js +49 -1
  209. package/dist/portal/session/session.d.ts +10 -8
  210. package/dist/portal/session/session.js +24 -32
  211. package/dist/surface/http-server.d.ts +3 -2
  212. package/dist/surface/http-server.js +8 -5
  213. package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
  214. package/dist/surface/mcp/mcp-proxy.js +2 -2
  215. package/dist/surface/mcp/mcp.js +5 -3
  216. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
  217. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
  218. package/dist/surface/mcp/proxy/proxy-log.js +3 -5
  219. package/dist/surface/mcp/server-instructions.js +9 -7
  220. package/dist/surface/tools/act/after-match.d.ts +17 -0
  221. package/dist/surface/tools/act/after-match.js +30 -0
  222. package/dist/surface/tools/act/durable.d.ts +69 -0
  223. package/dist/surface/tools/act/durable.js +139 -0
  224. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
  225. package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
  226. package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
  227. package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
  228. package/dist/surface/tools/act/proved-controls.d.ts +9 -0
  229. package/dist/surface/tools/act/proved-controls.js +11 -0
  230. package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
  231. package/dist/surface/tools/act/sequence-grade.js +32 -1
  232. package/dist/surface/tools/act-capsule.d.ts +7 -0
  233. package/dist/surface/tools/act-capsule.js +46 -1
  234. package/dist/surface/tools/act-sequence-tool.js +25 -8
  235. package/dist/surface/tools/act-tools.js +28 -52
  236. package/dist/surface/tools/assert/assert-grade.js +12 -1
  237. package/dist/surface/tools/assert/assert-verdict.js +2 -2
  238. package/dist/surface/tools/browser-tools.js +14 -2
  239. package/dist/surface/tools/coverage-tools.js +27 -1
  240. package/dist/surface/tools/dynamic-tools.js +29 -2
  241. package/dist/surface/tools/error-recovery.js +5 -6
  242. package/dist/surface/tools/feedback-invite.js +6 -4
  243. package/dist/surface/tools/harness-explore.d.ts +3 -2
  244. package/dist/surface/tools/harness-explore.js +12 -7
  245. package/dist/surface/tools/invoke-tool.js +12 -8
  246. package/dist/surface/tools/lease-tools.js +15 -6
  247. package/dist/surface/tools/live-call-text.js +4 -1
  248. package/dist/surface/tools/observe-tools.js +9 -1
  249. package/dist/surface/tools/tool-kit.d.ts +10 -0
  250. package/dist/surface/tools/tools.js +4 -0
  251. package/dist/telemetry/argument-shape.d.ts +0 -2
  252. package/dist/telemetry/argument-shape.js +1 -5
  253. package/dist/telemetry/error-class.d.ts +2 -3
  254. package/dist/telemetry/error-class.js +2 -3
  255. package/dist/telemetry/harness-drive.d.ts +0 -2
  256. package/dist/telemetry/harness-drive.js +0 -4
  257. package/dist/telemetry/onboarding-firsts.d.ts +2 -2
  258. package/dist/telemetry/onboarding-firsts.js +2 -2
  259. package/dist/wire-journal.d.ts +3 -0
  260. package/dist/wire-journal.js +4 -0
  261. package/dist/workspace-packages.d.ts +0 -2
  262. package/dist/workspace-packages.js +1 -9
  263. package/docs/README.md +4 -2
  264. package/docs/actions.mdx +3 -4
  265. package/docs/agent-cheatsheet.md +4 -4
  266. package/docs/architecture.md +6 -6
  267. package/docs/best-practices.mdx +1 -1
  268. package/docs/capabilities.mdx +32 -1
  269. package/docs/cli/cloud.mdx +20 -8
  270. package/docs/cli/doctor.mdx +4 -0
  271. package/docs/cli/feedback.mdx +2 -2
  272. package/docs/cli/gate.mdx +9 -4
  273. package/docs/cli/update.mdx +10 -0
  274. package/docs/cli.mdx +22 -7
  275. package/docs/desktop.mdx +14 -10
  276. package/docs/environment.mdx +87 -0
  277. package/docs/faq.mdx +18 -16
  278. package/docs/first-drive.md +5 -13
  279. package/docs/flows.md +21 -2
  280. package/docs/frameworks.mdx +29 -10
  281. package/docs/getting-started.md +15 -5
  282. package/docs/http-transport.md +6 -3
  283. package/docs/human-control.md +2 -2
  284. package/docs/index.mdx +16 -6
  285. package/docs/install-agentic.mdx +2 -2
  286. package/docs/install-manual.mdx +1 -1
  287. package/docs/integration-patterns.md +3 -3
  288. package/docs/multi-agent-testing.md +1 -1
  289. package/docs/packages/babel-plugin.mdx +1 -1
  290. package/docs/packages/browser.mdx +2 -2
  291. package/docs/packages/next.mdx +3 -0
  292. package/docs/packages/react.mdx +2 -2
  293. package/docs/packages/tauri.mdx +1 -1
  294. package/docs/packages/vite-plugin.mdx +4 -2
  295. package/docs/packages.mdx +9 -9
  296. package/docs/predicates.mdx +50 -5
  297. package/docs/quickstart.mdx +12 -10
  298. package/docs/recipes.mdx +2 -2
  299. package/docs/state-management.mdx +2 -2
  300. package/docs/system-map.md +1 -1
  301. package/docs/telemetry.md +1 -1
  302. package/docs/token-efficiency.md +3 -3
  303. package/docs/tools/act-and-wait.mdx +1 -0
  304. package/docs/tools/act-sequence.mdx +1 -1
  305. package/docs/tools/act.mdx +1 -1
  306. package/docs/tools/console.mdx +1 -1
  307. package/docs/tools/inspect.mdx +1 -1
  308. package/docs/tools/network.mdx +1 -1
  309. package/docs/tools/query.mdx +1 -1
  310. package/docs/tools/sessions.mdx +1 -1
  311. package/docs/tools/snapshot.mdx +1 -1
  312. package/docs/tools/state.mdx +1 -1
  313. package/docs/tools/wait-for.mdx +1 -1
  314. package/docs/usage.md +7 -7
  315. package/docs/use-cases.mdx +157 -0
  316. package/docs/vs-screenshots.mdx +3 -3
  317. package/docs/what-is-recorded.md +2 -2
  318. package/docs/what-is-reticle.mdx +17 -20
  319. package/docs/why-reticle.mdx +14 -21
  320. package/package.json +10 -9
  321. package/dist/judgement/capsule/minimize.d.ts +0 -12
  322. package/dist/judgement/capsule/minimize.js +0 -22
  323. package/dist/judgement/phenomena/phenomena.d.ts +0 -27
  324. package/dist/judgement/phenomena/phenomena.js +0 -101
  325. package/dist/language/flows/flow-report.d.ts +0 -22
  326. package/dist/language/flows/flow-report.js +0 -106
@@ -7,6 +7,7 @@
7
7
  import { span } from '../../trace.js';
8
8
  import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind, DANGEROUS_ACTION_CONFIRM_ARG, EventType, ReticleCommand, } from '@reticlehq/core';
9
9
  import { crawlEmptyNote } from './crawl-empty.js';
10
+ import { failedRequests, isConsoleError } from '../exhaust/session-fold.js';
10
11
  import { asNumber, asRecord, asString } from '@reticlehq/core';
11
12
  import { parseInteractive } from '@reticlehq/core';
12
13
  import { sourceOf } from '../../surface/tools/tools-helpers.js';
@@ -44,9 +45,6 @@ function isActivity(e) {
44
45
  e.type === EventType.ANIM_START ||
45
46
  e.type === EventType.ANIM_END);
46
47
  }
47
- function isConsoleError(e) {
48
- return e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT;
49
- }
50
48
  const SOURCE_FRAME = /^(.*):(\d+):(\d+)$/;
51
49
  const COMPONENT_STACK_FIELDS = ['componentStack', 'message'];
52
50
  const URL_SOURCE_PROTOCOLS = new Set([
@@ -156,14 +154,6 @@ function sourceFromConsoleError(event) {
156
154
  ? undefined
157
155
  : `${file}:${String(directLine)}`;
158
156
  }
159
- function failedRequests(events, floor) {
160
- return events.filter((e) => {
161
- if (e.type !== EventType.NET_REQUEST)
162
- return false;
163
- const status = asNumber(e.data['status']);
164
- return status !== undefined && status >= floor;
165
- });
166
- }
167
157
  /**
168
158
  * Controls whose correct behaviour IS to do nothing when clicked.
169
159
  *
@@ -198,17 +188,26 @@ export function legitimatelyInert(desc) {
198
188
  * fails — this is a disclosure, and failing the whole crawl to report on coverage would be worse
199
189
  * than the silence it is fixing.
200
190
  */
201
- async function countRevealed(session, opts, before) {
191
+ async function revealedItems(session, opts, before) {
202
192
  const snap = await session.command(ReticleCommand.SNAPSHOT, {
203
193
  mode: 'interactive',
204
194
  ...(opts.scope !== undefined ? { scope: opts.scope } : {}),
205
195
  });
206
196
  if (!snap.ok)
207
- return 0;
197
+ return [];
208
198
  const tree = (snap.result ?? {}).tree ?? '';
209
199
  const known = new Set(before.map((i) => (i.ref !== '' ? i.ref : i.desc)));
210
- return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc)).length;
200
+ return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc));
211
201
  }
202
+ /**
203
+ * How many times a crawl re-enumerates to follow what its own clicks revealed.
204
+ *
205
+ * ponytail: rounds, not a real frontier. Each round clicks what the previous one uncovered (a
206
+ * dashboard behind a login, a modal's contents), which is the depth a one-pass crawl never had. A
207
+ * page that reveals something new on every click would otherwise never end; past this it reports
208
+ * the rest as unvisited, as before. `maxSteps` still bounds the total.
209
+ */
210
+ const REVEAL_ROUNDS = 2;
212
211
  /**
213
212
  * The confirmation half of the dead-control rule. Kept separate so the rule above reads as one
214
213
  * question, and so the second click gets its own window: reusing the first would let the first
@@ -218,14 +217,14 @@ async function countRevealed(session, opts, before) {
218
217
  * wrong here returns `false`: a confirmation that cannot run is not evidence, and the direction that
219
218
  * costs a missed finding is better than the one that invents one.
220
219
  */
221
- async function stillSilent(session, item, settleMs, opts, sleep) {
220
+ async function stillSilent(session, item, settleMs, confirm, sleep) {
222
221
  const since = session.elapsed();
223
222
  session.beginAction?.(ReticleTool.CRAWL, { ref: item.ref, action: ActionType.CLICK });
224
223
  try {
225
224
  const again = await session.command(ReticleCommand.ACT, {
226
225
  ref: item.ref,
227
226
  action: ActionType.CLICK,
228
- args: true === opts.confirmDangerous ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
227
+ args: confirm ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
229
228
  });
230
229
  await sleep(settleMs);
231
230
  // A control that has GONE (the first click removed it) is not a dead control — it did something.
@@ -260,152 +259,167 @@ export async function crawl(session, opts, sleep) {
260
259
  const counts = { consoleErrors: 0, failedRequests: 0, deadControls: 0, contradictions: 0 };
261
260
  const seen = new Set();
262
261
  let stepsRun = 0;
263
- for (const item of items) {
264
- if (stepsRun >= maxSteps)
265
- break;
266
- // Dedupe by ref (the unique element), not by label — two "Delete"/"Edit" controls share a desc
267
- // but are different controls; collapsing them by label under-covers list/table UIs.
268
- const dedupeKey = item.ref !== '' ? item.ref : item.desc;
269
- if (seen.has(dedupeKey))
270
- continue; // don't re-click the same control
271
- seen.add(dedupeKey);
272
- stepsRun += 1;
273
- visited.push(item.desc);
274
- const since = session.elapsed();
275
- session.beginAction?.(ReticleTool.CRAWL, { ref: item.ref, action: ActionType.CLICK });
276
- let act;
277
- /*
278
- * A full page load reconnects the SDK, which rejects whatever command was in flight.
279
- *
280
- * On a server-rendered app that is what following a link DOES — every navigation replaces the
281
- * session — so the rejection is evidence the click worked, not that it failed. Crawl used to let
282
- * it propagate and died on link one of every Django, Rails or plain-HTML app.
283
- *
284
- * Recorded as a navigation rather than swallowed: a control that took the page somewhere is the
285
- * opposite of a dead one, and reporting it as dead would be the false negative crawl exists to
286
- * find.
287
- */
288
- let navigatedAway = false;
289
- try {
290
- // One span per control clicked, so a slow crawl names the control rather than reporting a
291
- // single multi-second total. The settle sleep below is INSIDE it deliberately: it is part of
292
- // what a step costs, and hiding it would make the fixed budget look free.
293
- act = await span('crawl.step', { ref: item.ref, desc: item.desc }, async () => {
294
- const clicked = await session.command(ReticleCommand.ACT, {
262
+ const found = [...items];
263
+ let frontier = items;
264
+ for (let round = 0;; round += 1) {
265
+ // `confirmDangerous` covers the controls on the page the caller pointed the crawl at. What a
266
+ // click REVEALED is new: the "Yes, delete" behind a "Delete" was confirmed with the same flag,
267
+ // so one call finished a deletion nobody authorized the second half of.
268
+ const confirm = 0 === round && true === opts.confirmDangerous;
269
+ for (const item of frontier) {
270
+ if (stepsRun >= maxSteps)
271
+ break;
272
+ // Dedupe by ref (the unique element), not by label — two "Delete"/"Edit" controls share a desc
273
+ // but are different controls; collapsing them by label under-covers list/table UIs.
274
+ const dedupeKey = item.ref !== '' ? item.ref : item.desc;
275
+ if (seen.has(dedupeKey))
276
+ continue; // don't re-click the same control
277
+ seen.add(dedupeKey);
278
+ stepsRun += 1;
279
+ visited.push(item.desc);
280
+ const since = session.elapsed();
281
+ session.beginAction?.(ReticleTool.CRAWL, { ref: item.ref, action: ActionType.CLICK });
282
+ let act;
283
+ /*
284
+ * A full page load reconnects the SDK, which rejects whatever command was in flight.
285
+ *
286
+ * On a server-rendered app that is what following a link DOES — every navigation replaces the
287
+ * session — so the rejection is evidence the click worked, not that it failed. Crawl used to let
288
+ * it propagate and died on link one of every Django, Rails or plain-HTML app.
289
+ *
290
+ * Recorded as a navigation rather than swallowed: a control that took the page somewhere is the
291
+ * opposite of a dead one, and reporting it as dead would be the false negative crawl exists to
292
+ * find.
293
+ */
294
+ let navigatedAway = false;
295
+ try {
296
+ // One span per control clicked, so a slow crawl names the control rather than reporting a
297
+ // single multi-second total. The settle sleep below is INSIDE it deliberately: it is part of
298
+ // what a step costs, and hiding it would make the fixed budget look free.
299
+ act = await span('crawl.step', { ref: item.ref, desc: item.desc }, async () => {
300
+ const clicked = await session.command(ReticleCommand.ACT, {
301
+ ref: item.ref,
302
+ action: ActionType.CLICK,
303
+ args: confirm ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
304
+ });
305
+ await sleep(settleMs);
306
+ return clicked;
307
+ });
308
+ }
309
+ catch (err) {
310
+ if (!isSessionReplacedError(err))
311
+ throw err;
312
+ navigatedAway = true;
313
+ }
314
+ finally {
315
+ // Close on every exit so a throw cannot leak the window onto the next control's events.
316
+ session.finishAction?.();
317
+ }
318
+ const events = session.eventsSince(since);
319
+ // Captured at act time, so it survives a click that unmounts its own control.
320
+ const src = sourceOf(asRecord(act?.result)['source']);
321
+ const controlSource = src === undefined ? undefined : `${src.file}:${String(src.line)}`;
322
+ const source = controlSource === undefined ? {} : { source: controlSource };
323
+ const errs = events.filter(isConsoleError);
324
+ for (const e of errs) {
325
+ counts.consoleErrors += 1;
326
+ const errorSource = sourceFromConsoleError(e) ?? controlSource;
327
+ anomalies.push({
328
+ kind: CrawlAnomalyKind.CONSOLE_ERROR,
295
329
  ref: item.ref,
296
- action: ActionType.CLICK,
297
- args: true === opts.confirmDangerous ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
330
+ desc: item.desc,
331
+ detail: asString(e.data['message']) ?? e.type,
332
+ ...(errorSource === undefined ? {} : { source: errorSource }),
298
333
  });
299
- await sleep(settleMs);
300
- return clicked;
301
- });
302
- }
303
- catch (err) {
304
- if (!isSessionReplacedError(err))
305
- throw err;
306
- navigatedAway = true;
307
- }
308
- finally {
309
- // Close on every exit so a throw cannot leak the window onto the next control's events.
310
- session.finishAction?.();
311
- }
312
- const events = session.eventsSince(since);
313
- // Captured at act time, so it survives a click that unmounts its own control.
314
- const src = sourceOf(asRecord(act?.result)['source']);
315
- const controlSource = src === undefined ? undefined : `${src.file}:${String(src.line)}`;
316
- const source = controlSource === undefined ? {} : { source: controlSource };
317
- const errs = events.filter(isConsoleError);
318
- for (const e of errs) {
319
- counts.consoleErrors += 1;
320
- const errorSource = sourceFromConsoleError(e) ?? controlSource;
321
- anomalies.push({
322
- kind: CrawlAnomalyKind.CONSOLE_ERROR,
323
- ref: item.ref,
324
- desc: item.desc,
325
- detail: asString(e.data['message']) ?? e.type,
326
- ...(errorSource === undefined ? {} : { source: errorSource }),
327
- });
328
- }
329
- for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
330
- counts.failedRequests += 1;
331
- const method = asString(e.data['method']) ?? '';
332
- const url = asString(e.data['url']) ?? '';
333
- const status = asNumber(e.data['status']);
334
- anomalies.push({
335
- kind: CrawlAnomalyKind.FAILED_REQUEST,
336
- ref: item.ref,
337
- desc: item.desc,
338
- detail: `${method} ${url} → ${status ?? ''}`.trim(),
339
- ...source,
340
- });
341
- }
342
- // CONTRADICTIONS: the channels disagree about what this click did. Reported per control, so a
343
- // crawl of an unknown app surfaces its false greens without anyone knowing where to look.
344
- // A crawl clicks controls that navigate, so its windows are the ones most likely to straddle a
345
- // document boundary — and a finding here is reported against a specific control by name.
346
- for (const c of findContradictions(events, {
347
- currentDocumentId: session.currentDocumentId,
348
- currentEditEpoch: session.currentEditEpoch,
349
- appOrigin: session.url,
350
- background: session.background,
351
- // A hidden tab cannot be observed, so its silence is not evidence. Stated from the session
352
- // rather than left to a heartbeat landing inside a 300ms window.
353
- pageHidden: session.throttled?.(),
354
- // The crawl's window IS one control's click, so it can name the floor the consequence rules
355
- // need — without it a crawl would report nothing about the UI moving, which is most of what a
356
- // crawl is for.
357
- actionSince: since,
358
- })) {
359
- counts.contradictions += 1;
360
- anomalies.push({
361
- kind: c.kind,
362
- ref: item.ref,
363
- desc: item.desc,
364
- detail: `${c.claim}, but ${c.counter} — ${c.detail}`,
365
- ...source,
366
- });
367
- }
368
- // DEAD: the click dispatched but the app produced no activity and no error to explain it.
369
- //
370
- // Unless the control is SUPPOSED to be inert. A control clicked twice with no reaction may be
371
- // legitimately inert — a `[disabled]` button, an already `[checked]` radio, a text input (a click
372
- // focuses it; mutating would be the surprise). An anomaly list that is all false positives is
373
- // worse than no anomaly list: the agent spends its budget disproving it and learns to skip it.
374
- //
375
- // Deliberately NOT fixed by counting focus as activity: focus moving is not the app reacting, and
376
- // treating it as such would make a genuinely dead button that takes focus look alive — trading
377
- // noise for the false negative this check exists to catch.
378
- const dispatched = asRecord(act?.result)['dispatched'] !== false && true === act?.ok;
379
- if (
380
- // A control that took the page somewhere is the OPPOSITE of a dead one. Without this, every
381
- // link on a server-rendered app would be reported as an anomaly by the check that exists to
382
- // find controls which do nothing.
383
- !navigatedAway &&
384
- dispatched &&
385
- 0 === errs.length &&
386
- !events.some(isActivity) &&
387
- !legitimatelyInert(item.desc)) {
388
- // CONFIRM IT. One silent sample is a sample, not a fact, and this is the strongest claim the
389
- // crawler makes — "your button does nothing" is the finding a reader acts on immediately.
390
- //
391
- // Measured on a fixture whose FIXED twin has a working primary CTA: the crawl reported it dead
392
- // on 2 of 3 runs, always a control the drive had already clicked successfully moments earlier.
393
- // A false positive in this tier is the most expensive kind there is — it is the tier a verdict
394
- // is built from, and the twin column is the whole claim this product makes.
395
- //
396
- // A second click costs one round trip on the rare control that looked dead, and nothing at all
397
- // on every control that did not. A genuinely dead control is silent twice; the flake is not.
398
- if (await stillSilent(session, item, settleMs, opts, sleep)) {
399
- counts.deadControls += 1;
334
+ }
335
+ for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
336
+ counts.failedRequests += 1;
337
+ const method = asString(e.data['method']) ?? '';
338
+ const url = asString(e.data['url']) ?? '';
339
+ const status = asNumber(e.data['status']);
400
340
  anomalies.push({
401
- kind: CrawlAnomalyKind.DEAD_CONTROL,
341
+ kind: CrawlAnomalyKind.FAILED_REQUEST,
402
342
  ref: item.ref,
403
343
  desc: item.desc,
404
- detail: 'clicked TWICE and the app did nothing either time (no DOM/network/route/signal change)',
344
+ detail: `${method} ${url} → ${status ?? ''}`.trim(),
405
345
  ...source,
406
346
  });
407
347
  }
348
+ // CONTRADICTIONS: the channels disagree about what this click did. Reported per control, so a
349
+ // crawl of an unknown app surfaces its false greens without anyone knowing where to look.
350
+ // A crawl clicks controls that navigate, so its windows are the ones most likely to straddle a
351
+ // document boundary — and a finding here is reported against a specific control by name.
352
+ for (const c of findContradictions(events, {
353
+ currentDocumentId: session.currentDocumentId,
354
+ currentEditEpoch: session.currentEditEpoch,
355
+ appOrigin: session.url,
356
+ background: session.background,
357
+ // A hidden tab cannot be observed, so its silence is not evidence. Stated from the session
358
+ // rather than left to a heartbeat landing inside a 300ms window.
359
+ pageHidden: session.throttled?.(),
360
+ // The crawl's window IS one control's click, so it can name the floor the consequence rules
361
+ // need — without it a crawl would report nothing about the UI moving, which is most of what a
362
+ // crawl is for.
363
+ actionSince: since,
364
+ })) {
365
+ counts.contradictions += 1;
366
+ anomalies.push({
367
+ kind: c.kind,
368
+ ref: item.ref,
369
+ desc: item.desc,
370
+ detail: `${c.claim}, but ${c.counter} — ${c.detail}`,
371
+ ...source,
372
+ });
373
+ }
374
+ // DEAD: the click dispatched but the app produced no activity and no error to explain it.
375
+ //
376
+ // Unless the control is SUPPOSED to be inert. A control clicked twice with no reaction may be
377
+ // legitimately inert — a `[disabled]` button, an already `[checked]` radio, a text input (a click
378
+ // focuses it; mutating would be the surprise). An anomaly list that is all false positives is
379
+ // worse than no anomaly list: the agent spends its budget disproving it and learns to skip it.
380
+ //
381
+ // Deliberately NOT fixed by counting focus as activity: focus moving is not the app reacting, and
382
+ // treating it as such would make a genuinely dead button that takes focus look alive — trading
383
+ // noise for the false negative this check exists to catch.
384
+ const dispatched = asRecord(act?.result)['dispatched'] !== false && true === act?.ok;
385
+ if (
386
+ // A control that took the page somewhere is the OPPOSITE of a dead one. Without this, every
387
+ // link on a server-rendered app would be reported as an anomaly by the check that exists to
388
+ // find controls which do nothing.
389
+ !navigatedAway &&
390
+ dispatched &&
391
+ 0 === errs.length &&
392
+ !events.some(isActivity) &&
393
+ !legitimatelyInert(item.desc)) {
394
+ // CONFIRM IT. One silent sample is a sample, not a fact, and this is the strongest claim the
395
+ // crawler makes — "your button does nothing" is the finding a reader acts on immediately.
396
+ //
397
+ // Measured on a fixture whose FIXED twin has a working primary CTA: the crawl reported it dead
398
+ // on 2 of 3 runs, always a control the drive had already clicked successfully moments earlier.
399
+ // A false positive in this tier is the most expensive kind there is — it is the tier a verdict
400
+ // is built from, and the twin column is the whole claim this product makes.
401
+ //
402
+ // A second click costs one round trip on the rare control that looked dead, and nothing at all
403
+ // on every control that did not. A genuinely dead control is silent twice; the flake is not.
404
+ if (await stillSilent(session, item, settleMs, confirm, sleep)) {
405
+ counts.deadControls += 1;
406
+ anomalies.push({
407
+ kind: CrawlAnomalyKind.DEAD_CONTROL,
408
+ ref: item.ref,
409
+ desc: item.desc,
410
+ detail: 'clicked TWICE and the app did nothing either time (no DOM/network/route/signal change)',
411
+ ...source,
412
+ });
413
+ }
414
+ }
408
415
  }
416
+ if (coverageCapped || stepsRun >= maxSteps || REVEAL_ROUNDS <= round)
417
+ break;
418
+ const next = await revealedItems(session, opts, found);
419
+ if (0 === next.length)
420
+ break;
421
+ found.push(...next);
422
+ frontier = next;
409
423
  }
410
424
  // One more look, AFTER the clicks. The enumeration above happened before any of them, so anything
411
425
  // the crawl itself unlocked — a dashboard behind a login, a modal's contents, a tab's panel — was
@@ -414,9 +428,9 @@ export async function crawl(session, opts, sleep) {
414
428
  // as new, and a surface that SHRANK (a modal closed) reports nothing, because nothing went
415
429
  // unvisited. Skipped when the snapshot was already capped: that case has its own note and a second
416
430
  // one would only muddy it.
417
- const revealed = coverageCapped ? 0 : await countRevealed(session, opts, items);
431
+ const revealed = coverageCapped ? 0 : (await revealedItems(session, opts, found)).length;
418
432
  return {
419
- interactiveFound: items.length,
433
+ interactiveFound: found.length,
420
434
  stepsRun,
421
435
  anomalies,
422
436
  counts,
@@ -424,7 +438,7 @@ export async function crawl(session, opts, sleep) {
424
438
  // True when coverage was bounded for EITHER reason: the step budget ran out, or the page was
425
439
  // bigger than one snapshot. Conflating "I stopped early" with "I saw everything" is what turns a
426
440
  // partial sweep into "all controls healthy".
427
- truncated: items.length > stepsRun || coverageCapped || revealed > 0,
441
+ truncated: found.length > stepsRun || coverageCapped || revealed > 0,
428
442
  ...(coverageCapped
429
443
  ? { coverageNote: CAPPED_SNAPSHOT_NOTE }
430
444
  : revealed > 0
@@ -434,7 +448,7 @@ export async function crawl(session, opts, sleep) {
434
448
  // same answer. See crawl-empty.
435
449
  ...(() => {
436
450
  const note = crawlEmptyNote({
437
- interactiveFound: items.length,
451
+ interactiveFound: found.length,
438
452
  stepsRun,
439
453
  maxSteps,
440
454
  truncated: coverageCapped,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `reticle_coverage { scope: "app" }` — fold this session into the ledger and say where "everything"
3
+ * stands, level by level.
4
+ */
5
+ import type { ReticleEvent } from '@reticlehq/core';
6
+ import type { FileSystemPort } from '../../memory/project/fs/fs-port.js';
7
+ import { type RawScriptCoverage } from './code-coverage.js';
8
+ import { type LevelReport } from './ledger.js';
9
+ export interface AppCoverageReport {
10
+ levels: LevelReport[];
11
+ /** Writes whose failure path was driven and the app claimed success anyway. Findings, not gaps. */
12
+ unhandledWrites: string[];
13
+ /** Levels below the best they ever reached. */
14
+ regressed: {
15
+ level: string;
16
+ was: number;
17
+ now: number;
18
+ }[];
19
+ note?: string;
20
+ }
21
+ export declare function foldAppCoverage(input: {
22
+ fs: FileSystemPort;
23
+ reticleRoot: string;
24
+ seen: readonly string[];
25
+ session: {
26
+ url: string;
27
+ actedLabels(): ReadonlySet<string>;
28
+ provedLabels?(): ReadonlySet<string>;
29
+ eventsSince(cursor: number): ReticleEvent[];
30
+ };
31
+ takeCode?: (sessionUrl: string) => Promise<readonly RawScriptCoverage[] | undefined>;
32
+ now: () => number;
33
+ }): Promise<AppCoverageReport>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `reticle_coverage { scope: "app" }` — fold this session into the ledger and say where "everything"
3
+ * stands, level by level.
4
+ */
5
+ import { emptyCodeCoverage, foldCodeCoverage } from './code-coverage.js';
6
+ import { LedgerStore, levelsOf, regressions } from './ledger.js';
7
+ import { sessionDelta } from './session-fold.js';
8
+ const NO_CODE_NOTE = 'The `executed` level is unmeasured here: code coverage needs a browser Reticle drives (`reticle drive`, or RETICLE_CDP_URL), and collection starts at the first coverage request, so drive and ask again. The other levels are real.';
9
+ export async function foldAppCoverage(input) {
10
+ const { session } = input;
11
+ const take = await input.takeCode?.(session.url);
12
+ const delta = sessionDelta({
13
+ seen: input.seen,
14
+ acted: session.actedLabels(),
15
+ proved: session.provedLabels?.() ?? [],
16
+ url: session.url,
17
+ events: session.eventsSince(0),
18
+ });
19
+ const ledger = await new LedgerStore(input.fs, input.reticleRoot).merge({
20
+ ...delta,
21
+ ...(take === undefined
22
+ ? {}
23
+ : { code: foldCodeCoverage(emptyCodeCoverage(), take), codeTakenAt: input.now() }),
24
+ });
25
+ return {
26
+ levels: levelsOf(ledger),
27
+ unhandledWrites: ledger.writes.unhandled,
28
+ regressed: regressions(ledger),
29
+ ...(take === undefined && 0 === Object.keys(ledger.code).length ? { note: NO_CODE_NOTE } : {}),
30
+ };
31
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What of the app's own code ever ran, from the browser Reticle drives.
3
+ *
4
+ * Every other coverage number measures what Reticle already knew about — routes it reached, controls
5
+ * it listed. None can say what NEVER ran, including a branch no screen shows. V8 function coverage
6
+ * can, because it is counted against the app's source rather than against what the driving found.
7
+ *
8
+ * ponytail: FUNCTION coverage, keyed by name and start offset in the served (transformed) module.
9
+ * It needs no source maps and answers "did this handler ever run". Line coverage needs the source
10
+ * map of each module; add it when function granularity proves too coarse.
11
+ */
12
+ /** One script as `page.coverage.stopJSCoverage()` reports it — only the fields read here. */
13
+ export interface RawScriptCoverage {
14
+ url: string;
15
+ functions: readonly {
16
+ functionName: string;
17
+ ranges: readonly {
18
+ startOffset: number;
19
+ endOffset: number;
20
+ count: number;
21
+ }[];
22
+ }[];
23
+ }
24
+ /** Per file, per function key: its name and whether it ever ran. JSON-safe so it can persist. */
25
+ export type CodeCoverage = Record<string, Record<string, {
26
+ name: string;
27
+ executed: boolean;
28
+ }>>;
29
+ export interface CodeSummary {
30
+ total: number;
31
+ executed: number;
32
+ files: {
33
+ file: string;
34
+ functions: number;
35
+ executed: number;
36
+ unexecuted: string[];
37
+ }[];
38
+ }
39
+ /** The repo-relative-looking path of an app script, or undefined for anything that is not the app. */
40
+ export declare function appFileOf(url: string): string | undefined;
41
+ export declare function emptyCodeCoverage(): CodeCoverage;
42
+ /**
43
+ * Fold one take into the accumulated coverage. A union: a function that ran once stays run, so a
44
+ * reload or a later drive can only add to what is known.
45
+ */
46
+ export declare function foldCodeCoverage(into: CodeCoverage, take: readonly RawScriptCoverage[]): CodeCoverage;
47
+ export declare function summarizeCode(coverage: CodeCoverage): CodeSummary;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * What of the app's own code ever ran, from the browser Reticle drives.
3
+ *
4
+ * Every other coverage number measures what Reticle already knew about — routes it reached, controls
5
+ * it listed. None can say what NEVER ran, including a branch no screen shows. V8 function coverage
6
+ * can, because it is counted against the app's source rather than against what the driving found.
7
+ *
8
+ * ponytail: FUNCTION coverage, keyed by name and start offset in the served (transformed) module.
9
+ * It needs no source maps and answers "did this handler ever run". Line coverage needs the source
10
+ * map of each module; add it when function granularity proves too coarse.
11
+ */
12
+ /** Path segments that are never the app under test. */
13
+ // `dist`: built output is not source, and a monorepo serves Reticle's own SDK from it.
14
+ const NOT_APP = /(?:^|\/)(?:node_modules|@vite|@id|@react-refresh|@reticlehq|\.vite|dist)(?:\/|$)/;
15
+ const SOURCE_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/;
16
+ /** The repo-relative-looking path of an app script, or undefined for anything that is not the app. */
17
+ export function appFileOf(url) {
18
+ let path;
19
+ try {
20
+ path = new URL(url).pathname;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ if (NOT_APP.test(path) || !SOURCE_FILE.test(path))
26
+ return undefined;
27
+ return path.replace(/^\/+/, '');
28
+ }
29
+ export function emptyCodeCoverage() {
30
+ return {};
31
+ }
32
+ /**
33
+ * Fold one take into the accumulated coverage. A union: a function that ran once stays run, so a
34
+ * reload or a later drive can only add to what is known.
35
+ */
36
+ export function foldCodeCoverage(into, take) {
37
+ const out = { ...into };
38
+ for (const script of take) {
39
+ const file = appFileOf(script.url);
40
+ if (file === undefined)
41
+ continue;
42
+ const fns = { ...(out[file] ?? {}) };
43
+ for (const f of script.functions) {
44
+ const first = f.ranges[0];
45
+ if (first === undefined)
46
+ continue;
47
+ // The module's own top-level body is reported as an anonymous function starting at 0. It
48
+ // always runs when the file loads, so counting it would inflate every file by one.
49
+ if ('' === f.functionName && 0 === first.startOffset)
50
+ continue;
51
+ const key = `${f.functionName}@${String(first.startOffset)}`;
52
+ const ran = first.count > 0 || true === fns[key]?.executed;
53
+ fns[key] = { name: '' === f.functionName ? '(anonymous)' : f.functionName, executed: ran };
54
+ }
55
+ out[file] = fns;
56
+ }
57
+ return out;
58
+ }
59
+ export function summarizeCode(coverage) {
60
+ const files = Object.entries(coverage)
61
+ .map(([file, fns]) => {
62
+ const list = Object.values(fns);
63
+ return {
64
+ file,
65
+ functions: list.length,
66
+ executed: list.filter((f) => f.executed).length,
67
+ unexecuted: list.filter((f) => !f.executed).map((f) => f.name),
68
+ };
69
+ })
70
+ .filter((f) => f.functions > 0)
71
+ .sort((a, b) => a.file.localeCompare(b.file));
72
+ return {
73
+ total: files.reduce((n, f) => n + f.functions, 0),
74
+ executed: files.reduce((n, f) => n + f.executed, 0),
75
+ files,
76
+ };
77
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `reticle_crawl { exhaustive: true }` — explore every reachable state, drive every write's failure
3
+ * path, take the code coverage, fold all of it into the ledger, and say what is left.
4
+ *
5
+ * It answers in crawl's own shape, so every caller that reads a crawl keeps working, and adds one
6
+ * `exhaustive` block with what only this mode knows.
7
+ */
8
+ import type { FileSystemPort } from '../../memory/project/fs/fs-port.js';
9
+ import { type RawScriptCoverage } from './code-coverage.js';
10
+ import { type PortSession } from './session-port.js';
11
+ /** Default action budget: generous, because every edge replays its path from a fresh load. */
12
+ export declare const EXHAUST_DEFAULT_ACTIONS = 400;
13
+ export declare function runExhaustive(input: {
14
+ sessions: {
15
+ get(id: string): PortSession | undefined;
16
+ };
17
+ session: PortSession;
18
+ startUrl: string;
19
+ maxActions: number;
20
+ settleMs: number;
21
+ /** What to type into a field, given the page it is on — a secret is only for its own origin. */
22
+ fillValue: (label: string, pageUrl: string) => string;
23
+ now: () => number;
24
+ sleep: (ms: number) => Promise<void>;
25
+ setMocks?: (sessionUrl: string, rules: {
26
+ urlContains: string;
27
+ method?: string;
28
+ abort?: boolean;
29
+ }[]) => Promise<boolean>;
30
+ takeCode?: (sessionUrl: string) => Promise<readonly RawScriptCoverage[] | undefined>;
31
+ fs: FileSystemPort;
32
+ reticleRoot: string;
33
+ }): Promise<Record<string, unknown>>;