@reticlehq/server 2.10.0 → 2.12.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 (290) hide show
  1. package/SKILL.md +73 -11
  2. package/dist/bridge/bridge.d.ts +0 -13
  3. package/dist/bridge/bridge.js +1 -1
  4. package/dist/bridge/bridge.test-harness.d.ts +2 -0
  5. package/dist/bridge/bridge.test-harness.js +8 -1
  6. package/dist/capsule/capsule-store.d.ts +2 -1
  7. package/dist/capsule/capsule-store.js +1 -1
  8. package/dist/capsule/causal-summary.d.ts +2 -2
  9. package/dist/chromium-launch-options.d.ts +28 -0
  10. package/dist/chromium-launch-options.js +29 -0
  11. package/dist/cli/cli-doctor.js +8 -0
  12. package/dist/cli/cli-flow-commands.d.ts +3 -1
  13. package/dist/cli/cli-flow-commands.js +34 -4
  14. package/dist/cli/cli-kill.d.ts +2 -1
  15. package/dist/cli/cli-parse.d.ts +3 -1
  16. package/dist/cli/cli-parse.js +29 -7
  17. package/dist/cli/cli-update-commands.js +20 -1
  18. package/dist/cli/cli-verify.d.ts +1 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/cloud-cli.js +12 -6
  21. package/dist/cli/config-discovery.d.ts +2 -1
  22. package/dist/cli/doctor-rows.d.ts +3 -1
  23. package/dist/cli/doctor-rows.js +3 -1
  24. package/dist/cli/gate-exit.d.ts +21 -0
  25. package/dist/cli/gate-exit.js +21 -0
  26. package/dist/cli/gate-hook-message.d.ts +33 -0
  27. package/dist/cli/gate-hook-message.js +46 -0
  28. package/dist/cli/mcp-command.d.ts +25 -0
  29. package/dist/cli/mcp-command.js +143 -0
  30. package/dist/cli/sibling-ports.d.ts +37 -0
  31. package/dist/cli/sibling-ports.js +56 -0
  32. package/dist/cli.js +37 -110
  33. package/dist/cloud/cloud-config.d.ts +2 -1
  34. package/dist/cloud/cloud-sync.d.ts +11 -0
  35. package/dist/cloud/cloud-sync.js +32 -0
  36. package/dist/crawl/crawl.d.ts +9 -13
  37. package/dist/crawl/crawl.js +20 -3
  38. package/dist/daemon/daemon-resolve.d.ts +79 -0
  39. package/dist/daemon/daemon-resolve.js +118 -0
  40. package/dist/daemon/free-port.d.ts +29 -0
  41. package/dist/daemon/free-port.js +55 -0
  42. package/dist/daemon/heartbeat.d.ts +0 -4
  43. package/dist/daemon/heartbeat.js +2 -2
  44. package/dist/dev/stale-issue-guard.d.ts +41 -0
  45. package/dist/dev/stale-issue-guard.js +75 -0
  46. package/dist/domain/domain-tools.js +3 -2
  47. package/dist/events/contradictions.d.ts +41 -0
  48. package/dist/events/contradictions.js +125 -12
  49. package/dist/events/declared.d.ts +14 -0
  50. package/dist/events/declared.js +33 -0
  51. package/dist/events/echo-mismatch.js +14 -0
  52. package/dist/events/observed-in-window.d.ts +12 -0
  53. package/dist/events/observed-in-window.js +17 -0
  54. package/dist/events/predicate-asks.d.ts +30 -0
  55. package/dist/events/predicate-asks.js +48 -0
  56. package/dist/events/predicate-eval.js +254 -25
  57. package/dist/events/predicate-parse.d.ts +0 -6
  58. package/dist/events/predicate-parse.js +51 -5
  59. package/dist/events/predicate-schema.d.ts +62 -0
  60. package/dist/events/predicate-schema.js +162 -4
  61. package/dist/events/predicate.d.ts +15 -0
  62. package/dist/events/predicate.js +41 -2
  63. package/dist/flows/coverage.d.ts +0 -8
  64. package/dist/flows/coverage.js +1 -1
  65. package/dist/flows/decision.d.ts +5 -1
  66. package/dist/flows/decision.js +33 -7
  67. package/dist/flows/flake.d.ts +0 -2
  68. package/dist/flows/flake.js +1 -1
  69. package/dist/flows/flow-intent.d.ts +52 -0
  70. package/dist/flows/flow-intent.js +123 -0
  71. package/dist/flows/flow-replay-run.js +27 -4
  72. package/dist/flows/flow-success.js +15 -3
  73. package/dist/flows/flow-tools.js +65 -8
  74. package/dist/flows/flows.d.ts +6 -1
  75. package/dist/flows/flows.js +34 -22
  76. package/dist/flows/predicate-to-expect.js +14 -3
  77. package/dist/flows/server-verify.js +16 -2
  78. package/dist/flows/verify-change-tools.js +9 -2
  79. package/dist/honesty/blind-spots.d.ts +8 -16
  80. package/dist/honesty/blind-spots.js +18 -5
  81. package/dist/honesty/feature-capture.d.ts +150 -0
  82. package/dist/honesty/feature-capture.js +233 -0
  83. package/dist/honesty/gap-ledger.d.ts +52 -0
  84. package/dist/honesty/gap-ledger.js +63 -0
  85. package/dist/honesty/instrumentation-gaps.d.ts +79 -0
  86. package/dist/honesty/instrumentation-gaps.js +77 -0
  87. package/dist/honesty/observability.d.ts +37 -0
  88. package/dist/honesty/observability.js +40 -0
  89. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  90. package/dist/honesty/tool-hit-rate.js +90 -0
  91. package/dist/honesty/undeclared-change.d.ts +51 -0
  92. package/dist/honesty/undeclared-change.js +56 -0
  93. package/dist/honesty/verified.d.ts +15 -0
  94. package/dist/honesty/verified.js +68 -4
  95. package/dist/http-server.d.ts +0 -1
  96. package/dist/http-server.js +1 -1
  97. package/dist/hunt/hunt-report.d.ts +0 -2
  98. package/dist/hunt/hunt-report.js +1 -1
  99. package/dist/impact/impact-store.d.ts +0 -5
  100. package/dist/impact/impact-store.js +1 -1
  101. package/dist/index.d.ts +4 -2
  102. package/dist/index.js +41 -0
  103. package/dist/init/agent-rules.d.ts +17 -0
  104. package/dist/init/agent-rules.js +31 -1
  105. package/dist/init/capabilities.d.ts +0 -2
  106. package/dist/init/capabilities.js +1 -1
  107. package/dist/init/closing-hint.d.ts +36 -0
  108. package/dist/init/closing-hint.js +120 -0
  109. package/dist/init/confirm.d.ts +0 -25
  110. package/dist/init/confirm.js +3 -3
  111. package/dist/init/cra.d.ts +14 -1
  112. package/dist/init/cra.js +43 -12
  113. package/dist/init/dev-script.d.ts +48 -0
  114. package/dist/init/dev-script.js +63 -0
  115. package/dist/init/mcp.d.ts +16 -5
  116. package/dist/init/mcp.js +26 -10
  117. package/dist/init/node-io.js +3 -0
  118. package/dist/init/plan-framework.d.ts +0 -5
  119. package/dist/init/plan-framework.js +83 -9
  120. package/dist/init/plan.d.ts +10 -30
  121. package/dist/init/plan.js +30 -15
  122. package/dist/init/refresh-rules.d.ts +39 -0
  123. package/dist/init/refresh-rules.js +51 -0
  124. package/dist/init/run.d.ts +2 -10
  125. package/dist/init/run.js +26 -68
  126. package/dist/init/slash-command.d.ts +1 -1
  127. package/dist/init/slash-command.js +33 -10
  128. package/dist/init/snippets.js +53 -10
  129. package/dist/input/real-input.d.ts +2 -0
  130. package/dist/input/real-input.js +3 -2
  131. package/dist/input/scroll-find.d.ts +7 -4
  132. package/dist/input/scroll-find.js +63 -25
  133. package/dist/intent/inline-intent.d.ts +46 -0
  134. package/dist/intent/inline-intent.js +103 -0
  135. package/dist/intent/intent-store.d.ts +37 -0
  136. package/dist/intent/intent-store.js +112 -0
  137. package/dist/intent/intent-tools.d.ts +2 -0
  138. package/dist/intent/intent-tools.js +80 -0
  139. package/dist/intent/open-intents.d.ts +21 -0
  140. package/dist/intent/open-intents.js +25 -0
  141. package/dist/journal/envelope.d.ts +2 -2
  142. package/dist/journal/journal-recorder.d.ts +23 -0
  143. package/dist/journal/journal-recorder.js +35 -0
  144. package/dist/license/license.d.ts +8 -2
  145. package/dist/license/license.js +10 -15
  146. package/dist/mcp/attach-memory.d.ts +67 -0
  147. package/dist/mcp/attach-memory.js +142 -0
  148. package/dist/mcp/mcp-proxy.d.ts +0 -7
  149. package/dist/mcp/mcp-proxy.js +14 -2
  150. package/dist/mcp/mcp.d.ts +2 -0
  151. package/dist/mcp/mcp.js +45 -23
  152. package/dist/mcp/server-instructions.js +45 -3
  153. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  154. package/dist/mcp/tool-catalog-cache.js +10 -0
  155. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  156. package/dist/oracles/flow-instrument-gaps.js +4 -5
  157. package/dist/oracles/self-instrument.d.ts +9 -9
  158. package/dist/oracles/self-instrument.js +19 -13
  159. package/dist/pool/browser-pool.d.ts +12 -1
  160. package/dist/pool/browser-pool.js +31 -1
  161. package/dist/pool/playwright-launcher.js +3 -1
  162. package/dist/project/artifact-root.d.ts +83 -0
  163. package/dist/project/artifact-root.js +112 -0
  164. package/dist/project/fs-port.d.ts +3 -0
  165. package/dist/project/fs-port.js +3 -2
  166. package/dist/project/memory-fs.d.ts +9 -0
  167. package/dist/project/memory-fs.js +93 -0
  168. package/dist/project/project-store.d.ts +17 -0
  169. package/dist/project/project-store.js +33 -0
  170. package/dist/project/project-tools.js +3 -32
  171. package/dist/project/remember-project.d.ts +24 -0
  172. package/dist/project/remember-project.js +40 -0
  173. package/dist/project/reticle-dir.d.ts +2 -0
  174. package/dist/project/reticle-dir.js +1 -0
  175. package/dist/project/session-root.d.ts +15 -0
  176. package/dist/project/session-root.js +31 -0
  177. package/dist/runs/context-tools.d.ts +2 -0
  178. package/dist/runs/context-tools.js +89 -0
  179. package/dist/runs/run-context.d.ts +40 -0
  180. package/dist/runs/run-context.js +158 -0
  181. package/dist/runs/run-diff.d.ts +2 -1
  182. package/dist/runs/run-tools.js +2 -1
  183. package/dist/runs/verification-sync.js +2 -2
  184. package/dist/session/dev-server-probe.d.ts +0 -8
  185. package/dist/session/dev-server-probe.js +1 -1
  186. package/dist/session/last-act.d.ts +7 -1
  187. package/dist/session/last-act.js +2 -2
  188. package/dist/session/lease-availability.d.ts +40 -0
  189. package/dist/session/lease-availability.js +43 -0
  190. package/dist/session/live-control-tools.js +1 -42
  191. package/dist/session/no-session-diagnosis.d.ts +20 -0
  192. package/dist/session/no-session-diagnosis.js +41 -12
  193. package/dist/session/no-session-next-action.d.ts +5 -0
  194. package/dist/session/no-session-next-action.js +35 -3
  195. package/dist/session/no-session-watch.d.ts +8 -0
  196. package/dist/session/no-session-watch.js +83 -65
  197. package/dist/session/output-budget.js +4 -1
  198. package/dist/session/session-health.d.ts +9 -0
  199. package/dist/session/session-health.js +23 -0
  200. package/dist/session/session-manager.js +50 -1
  201. package/dist/session/session-successor.d.ts +37 -0
  202. package/dist/session/session-successor.js +78 -0
  203. package/dist/session/session.d.ts +40 -1
  204. package/dist/session/session.js +84 -2
  205. package/dist/session/stall-clock.d.ts +18 -0
  206. package/dist/session/stall-clock.js +48 -0
  207. package/dist/telemetry/automation-hint.d.ts +2 -1
  208. package/dist/telemetry/daemon-telemetry.js +12 -23
  209. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  210. package/dist/telemetry/project-profile.d.ts +0 -8
  211. package/dist/telemetry/project-profile.js +1 -1
  212. package/dist/telemetry/run-telemetry.d.ts +0 -2
  213. package/dist/telemetry/run-telemetry.js +1 -1
  214. package/dist/telemetry/session-metrics.d.ts +1 -1
  215. package/dist/telemetry/session-metrics.js +1 -1
  216. package/dist/telemetry/telemetry.d.ts +12 -3
  217. package/dist/telemetry/telemetry.js +25 -5
  218. package/dist/telemetry/verification-of.js +2 -2
  219. package/dist/tools/act-tools.js +159 -24
  220. package/dist/tools/assert-source.d.ts +22 -0
  221. package/dist/tools/assert-source.js +53 -0
  222. package/dist/tools/assert-verdict.d.ts +21 -2
  223. package/dist/tools/assert-verdict.js +59 -4
  224. package/dist/tools/browser-tools.js +0 -20
  225. package/dist/tools/complete-snapshot.d.ts +48 -0
  226. package/dist/tools/complete-snapshot.js +75 -0
  227. package/dist/tools/contract-tools.js +5 -3
  228. package/dist/tools/coverage-tools.d.ts +10 -1
  229. package/dist/tools/coverage-tools.js +124 -37
  230. package/dist/tools/dynamic-tools.js +29 -8
  231. package/dist/tools/error-recovery.d.ts +24 -14
  232. package/dist/tools/error-recovery.js +70 -9
  233. package/dist/tools/feedback-tools.d.ts +1 -1
  234. package/dist/tools/feedback-tools.js +2 -2
  235. package/dist/tools/invoke-tool.js +62 -2
  236. package/dist/tools/lease-hint.d.ts +10 -0
  237. package/dist/tools/lease-hint.js +53 -4
  238. package/dist/tools/lease-tools.d.ts +5 -1
  239. package/dist/tools/lease-tools.js +22 -8
  240. package/dist/tools/navigate-arrival.d.ts +0 -2
  241. package/dist/tools/navigate-arrival.js +1 -1
  242. package/dist/tools/observe-tools.js +70 -27
  243. package/dist/tools/read-tools.js +5 -3
  244. package/dist/tools/real-input-attempt.d.ts +22 -0
  245. package/dist/tools/real-input-attempt.js +153 -1
  246. package/dist/tools/reconcile-tools.js +18 -4
  247. package/dist/tools/resolve-target.d.ts +0 -7
  248. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  249. package/dist/tools/sdk-marker-probe.js +57 -0
  250. package/dist/tools/snapshot-delta.d.ts +1 -1
  251. package/dist/tools/tool-kit.d.ts +28 -1
  252. package/dist/tools/tool-kit.js +16 -0
  253. package/dist/tools/tool-names.d.ts +3 -0
  254. package/dist/tools/tool-names.js +3 -0
  255. package/dist/tools/tool-surface.d.ts +85 -6
  256. package/dist/tools/tool-surface.js +122 -2
  257. package/dist/tools/tools.js +22 -4
  258. package/dist/tools/unadvertised-help.js +4 -4
  259. package/dist/tools/verify-next-baton.d.ts +53 -0
  260. package/dist/tools/verify-next-baton.js +52 -0
  261. package/dist/update/update-nudge.d.ts +7 -10
  262. package/dist/update/update-nudge.js +12 -3
  263. package/dist/update/updater.d.ts +1 -1
  264. package/docs/README.md +3 -1
  265. package/docs/cli/cloud.mdx +1 -1
  266. package/docs/cli/doctor.mdx +1 -0
  267. package/docs/cli/verify.mdx +4 -5
  268. package/docs/desktop.mdx +1 -1
  269. package/docs/docs.json +3 -3
  270. package/docs/enterprise.md +3 -1
  271. package/docs/gates.md +1 -1
  272. package/docs/install-manual.mdx +1 -1
  273. package/docs/license-activation.mdx +145 -0
  274. package/docs/packages/browser.mdx +2 -2
  275. package/docs/packages/server.mdx +1 -1
  276. package/docs/packages.mdx +8 -6
  277. package/docs/platform-integration.md +17 -0
  278. package/docs/predicates.mdx +8 -0
  279. package/docs/reticle-manual.md +106 -0
  280. package/docs/telemetry-contract.md +1 -12
  281. package/docs/telemetry-events.mdx +1 -34
  282. package/docs/telemetry.md +0 -1
  283. package/docs/tools-tools-and-run.mdx +2 -4
  284. package/docs/troubleshooting.mdx +9 -0
  285. package/docs/usage.md +3 -2
  286. package/package.json +2 -2
  287. package/dist/session/session-readiness.d.ts +0 -34
  288. package/dist/session/session-readiness.js +0 -36
  289. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  290. package/dist/telemetry/instrumentation-stall.js +0 -103
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A port this machine will actually let us bind.
3
+ *
4
+ * Once daemons are per-project, "which port" stops being a constant and becomes a question. The
5
+ * answer must not be "the next number up": probing 4401, 4402, 4403 races every other agent doing
6
+ * the same arithmetic at the same moment, and the loser finds out at bind time, which is after it has
7
+ * already told the caller which port to use.
8
+ *
9
+ * So the OS assigns it. Binding port 0 asks the kernel for a free one and hands back the number it
10
+ * chose, and nothing else can be holding it at that instant. There is still a window between closing
11
+ * the probe socket and the daemon binding for real — unavoidable without passing the listener itself
12
+ * to the child — but it is microseconds wide and loses to a retry, where a guessed number loses every
13
+ * time on a busy machine.
14
+ *
15
+ * The preferred port is tried first so the ordinary single-project install keeps landing on the
16
+ * documented default, and every doc, log line and troubleshooting page that says 4400 stays true.
17
+ */
18
+ import { createServer } from 'node:net';
19
+ import { LOOPBACK_HOST } from '@reticlehq/core';
20
+ /** Can we bind `port` on the loopback interface right now? */
21
+ export function portIsFree(port, host = LOOPBACK_HOST) {
22
+ return new Promise((resolve) => {
23
+ const probe = createServer();
24
+ probe.once('error', () => resolve(false));
25
+ probe.listen(port, host, () => {
26
+ probe.close(() => resolve(true));
27
+ });
28
+ });
29
+ }
30
+ /** Ask the kernel for a free port. Rejects only if the machine cannot bind loopback at all. */
31
+ export function osAssignedPort(host = LOOPBACK_HOST) {
32
+ return new Promise((resolve, reject) => {
33
+ const probe = createServer();
34
+ probe.once('error', reject);
35
+ probe.listen(0, host, () => {
36
+ const address = probe.address();
37
+ if (null === address || 'string' === typeof address) {
38
+ probe.close(() => reject(new Error('could not read an OS-assigned port')));
39
+ return;
40
+ }
41
+ const { port } = address;
42
+ probe.close(() => resolve(port));
43
+ });
44
+ });
45
+ }
46
+ /**
47
+ * `preferred` when it is free, otherwise whatever the OS gives us.
48
+ *
49
+ * Injectable so the decision is unit-testable without binding anything: the interesting case is the
50
+ * one where the preferred port is taken, and a test must be able to say so without racing a real
51
+ * socket.
52
+ */
53
+ export async function pickDaemonPortToBind(preferred, isFree = portIsFree, assign = osAssignedPort) {
54
+ return (await isFree(preferred)) ? preferred : assign();
55
+ }
@@ -19,10 +19,6 @@
19
19
  */
20
20
  /** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
21
21
  export declare const DAEMON_HEARTBEAT_EVENT = "reticle_daemon_alive";
22
- /** Written by installExitTrace when the process exits through Node. */
23
- export declare const DAEMON_EXIT_EVENT = "reticle_daemon_exiting";
24
- /** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
25
- export declare const DAEMON_SIGNAL_EVENT = "reticle_daemon_signalled";
26
22
  /**
27
23
  * How often a live daemon says so.
28
24
  *
@@ -20,9 +20,9 @@
20
20
  /** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
21
21
  export const DAEMON_HEARTBEAT_EVENT = 'reticle_daemon_alive';
22
22
  /** Written by installExitTrace when the process exits through Node. */
23
- export const DAEMON_EXIT_EVENT = 'reticle_daemon_exiting';
23
+ const DAEMON_EXIT_EVENT = 'reticle_daemon_exiting';
24
24
  /** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
25
- export const DAEMON_SIGNAL_EVENT = 'reticle_daemon_signalled';
25
+ const DAEMON_SIGNAL_EVENT = 'reticle_daemon_signalled';
26
26
  /**
27
27
  * How often a live daemon says so.
28
28
  *
@@ -0,0 +1,41 @@
1
+ /**
2
+ * An issue we have already fixed must not still read as available work.
3
+ *
4
+ * Three contributor PRs died on arrival in one week for this. #373 was filed at 09:52 and fixed by
5
+ * us at 12:33 the same day, then left open for three more days — somebody started on it on day two.
6
+ * #398 shipped and stayed open; a second contributor rebuilt it from scratch. `GITHUB.md` already
7
+ * documents the same failure costing three contributors before either of those, which makes this the
8
+ * third recurrence of a defect we have written down and asked ourselves to remember.
9
+ *
10
+ * Remembering is not a mechanism, and this is the smallest one that works: a commit saying it closes
11
+ * an issue is a claim that the issue is done. If that issue is still open and unlabelled afterwards,
12
+ * the tracker is lying to whoever reads it next — and the person it lies to is by definition someone
13
+ * looking for something to work on.
14
+ *
15
+ * ## Why `fixed-pending-release` is an accepted answer
16
+ *
17
+ * Work fixed on an unreleased branch is legitimately still open; the issue closes when the version
18
+ * ships. The label is what tells a contributor not to start, so the label — not the closure — is
19
+ * what this guard is actually asking for. That is also why the fix is cheap: nobody has to change
20
+ * when they close things, only to say so.
21
+ *
22
+ * Pure. The git log and the GitHub lookup belong to the caller, which is what makes every branch
23
+ * below testable without a network or a repo.
24
+ */
25
+ export interface IssueState {
26
+ number: number;
27
+ state: 'open' | 'closed';
28
+ labels: readonly string[];
29
+ }
30
+ export declare function closedRefsIn(commitMessage: string): number[];
31
+ /**
32
+ * Of the issues these commits claimed to close, the ones still reading as available work.
33
+ *
34
+ * An issue that could not be looked up is deliberately silent rather than failing. A rate limit, a
35
+ * transferred issue or a deleted one would otherwise turn this red for a reason unrelated to what it
36
+ * guards, and a guard that goes red for unrelated reasons gets disabled — which costs more than the
37
+ * defect it was catching.
38
+ */
39
+ export declare function staleIssues(claimed: readonly number[], issues: readonly IssueState[]): number[];
40
+ /** The message the guard prints. Held here so the check and its tests cannot describe it differently. */
41
+ export declare function staleIssueReport(stale: readonly number[]): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * An issue we have already fixed must not still read as available work.
3
+ *
4
+ * Three contributor PRs died on arrival in one week for this. #373 was filed at 09:52 and fixed by
5
+ * us at 12:33 the same day, then left open for three more days — somebody started on it on day two.
6
+ * #398 shipped and stayed open; a second contributor rebuilt it from scratch. `GITHUB.md` already
7
+ * documents the same failure costing three contributors before either of those, which makes this the
8
+ * third recurrence of a defect we have written down and asked ourselves to remember.
9
+ *
10
+ * Remembering is not a mechanism, and this is the smallest one that works: a commit saying it closes
11
+ * an issue is a claim that the issue is done. If that issue is still open and unlabelled afterwards,
12
+ * the tracker is lying to whoever reads it next — and the person it lies to is by definition someone
13
+ * looking for something to work on.
14
+ *
15
+ * ## Why `fixed-pending-release` is an accepted answer
16
+ *
17
+ * Work fixed on an unreleased branch is legitimately still open; the issue closes when the version
18
+ * ships. The label is what tells a contributor not to start, so the label — not the closure — is
19
+ * what this guard is actually asking for. That is also why the fix is cheap: nobody has to change
20
+ * when they close things, only to say so.
21
+ *
22
+ * Pure. The git log and the GitHub lookup belong to the caller, which is what makes every branch
23
+ * below testable without a network or a repo.
24
+ */
25
+ /** The label that means "fixed, waiting for a release" — an honest reason to still be open. */
26
+ const PENDING_RELEASE_LABEL = 'fixed-pending-release';
27
+ /**
28
+ * Issue numbers a commit message CLAIMS to have closed.
29
+ *
30
+ * Only the closing verbs GitHub itself acts on. A bare `#447`, a `Refs #447` or a `see #447` is how
31
+ * a change points at context it did not close — the exact habit we want to encourage — so counting
32
+ * those would make the guard cry wolf on good behaviour, and a check that cries wolf is one people
33
+ * learn to skip.
34
+ */
35
+ const CLOSING_REF = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
36
+ export function closedRefsIn(commitMessage) {
37
+ const seen = new Set();
38
+ for (const match of commitMessage.matchAll(CLOSING_REF)) {
39
+ const raw = match[1];
40
+ if (raw === undefined)
41
+ continue;
42
+ const parsed = Number.parseInt(raw, 10);
43
+ if (Number.isFinite(parsed))
44
+ seen.add(parsed);
45
+ }
46
+ return [...seen];
47
+ }
48
+ /**
49
+ * Of the issues these commits claimed to close, the ones still reading as available work.
50
+ *
51
+ * An issue that could not be looked up is deliberately silent rather than failing. A rate limit, a
52
+ * transferred issue or a deleted one would otherwise turn this red for a reason unrelated to what it
53
+ * guards, and a guard that goes red for unrelated reasons gets disabled — which costs more than the
54
+ * defect it was catching.
55
+ */
56
+ export function staleIssues(claimed, issues) {
57
+ const byNumber = new Map(issues.map((issue) => [issue.number, issue]));
58
+ return claimed.filter((number) => {
59
+ const issue = byNumber.get(number);
60
+ if (issue === undefined)
61
+ return false;
62
+ if ('closed' === issue.state)
63
+ return false;
64
+ return !issue.labels.includes(PENDING_RELEASE_LABEL);
65
+ });
66
+ }
67
+ /** The message the guard prints. Held here so the check and its tests cannot describe it differently. */
68
+ export function staleIssueReport(stale) {
69
+ const list = stale.map((n) => `#${String(n)}`).join(', ');
70
+ return (`${list} ${1 === stale.length ? 'is' : 'are'} still OPEN, and a commit on this branch says it ` +
71
+ `closes ${1 === stale.length ? 'it' : 'them'}. Anyone browsing the tracker reads that as work ` +
72
+ `nobody has started. Close ${1 === stale.length ? 'it' : 'them'}, or label ` +
73
+ `${1 === stale.length ? 'it' : 'them'} \`${PENDING_RELEASE_LABEL}\` if the fix is waiting on a ` +
74
+ `release. This has cost three contributors an evening each; it is not bookkeeping.`);
75
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { asNumber } from '../tools/tools-helpers.js';
2
+ import { sessionRoot } from '../project/session-root.js';
3
+ import { asNumber, asString } from '../tools/tools-helpers.js';
3
4
  import { ReticleTool } from '../tools/tool-names.js';
4
5
  import { readContract } from '../project/reticle-dir.js';
5
6
  import { buildDomainModel } from './domain-model.js';
@@ -74,7 +75,7 @@ export const DOMAIN_TOOLS = [
74
75
  if (loaded.ok)
75
76
  flows.push(loaded.value);
76
77
  }
77
- const contract = await readContract(deps.fs, deps.reticleRoot);
78
+ const contract = await readContract(deps.fs, sessionRoot(deps, asString(args['sessionId'])));
78
79
  const project = await deps.project.read();
79
80
  const runs = project.ok ? project.file.runs : [];
80
81
  const model = buildDomainModel(flows, contract.ok ? contract.capabilities : null, runs);
@@ -113,5 +113,46 @@ export interface ContradictionOptions {
113
113
  * with no view.
114
114
  */
115
115
  renderProved?: boolean | undefined;
116
+ /**
117
+ * The document currently under observation, as the session derived it from its own event stream.
118
+ *
119
+ * Every rule below reasons about "the same window", and a window is scoped by time and by
120
+ * ring-buffer capacity and by nothing else — so it can still hold the traffic of a page a full
121
+ * navigation or a reload has already thrown away. Naming that traffic as the cause of an action
122
+ * taken now is true about the bytes and false about the world.
123
+ *
124
+ * Undefined means nobody could say which document is current (an SDK too old to stamp one, a caller
125
+ * with no session in hand), and the scoping then does nothing at all — `isSameDocument` treats
126
+ * absence as current on both sides, so the engine behaves exactly as it did before this existed.
127
+ */
128
+ currentDocumentId?: string | undefined;
129
+ /**
130
+ * The edit epoch currently in force, as the session derived it from its own event stream.
131
+ *
132
+ * The edit-shaped half of `currentDocumentId`. A hot update replaces modules and re-renders inside
133
+ * the SAME document, so the document id cannot see it and observations of code the agent has
134
+ * already rewritten go on answering for it in silence.
135
+ *
136
+ * Undefined means nobody could say (an SDK too old to stamp one, a page with no hot-update channel,
137
+ * a caller with no session in hand) and the scoping then does nothing at all — `isSameEditEpoch`
138
+ * treats absence as current on both sides.
139
+ */
140
+ currentEditEpoch?: number | undefined;
116
141
  }
142
+ /**
143
+ * Cross-channel contradictions in this window, with the edit-epoch caveat attached when it applies.
144
+ *
145
+ * Cross-epoch evidence is LABELLED rather than excluded, which is the opposite of what the document
146
+ * scoping does, and the difference is the point. A navigation is total — it throws away the page,
147
+ * the refs, the in-flight requests and the state — so nothing recorded before it is still about the
148
+ * world, and dropping it is the only honest option. A hot update is not: most modules, most of the
149
+ * DOM, the whole network log and every console line survive one, so most of what was observed a
150
+ * second before an edit is still true a second after. Excluding it would empty windows that hold
151
+ * real findings, and an emptied window reads as "nothing happened" — which is the more expensive of
152
+ * the two wrong answers and the one this whole family of checks exists to prevent.
153
+ *
154
+ * So the findings stand and the caveat is said out loud, and only when it is unambiguous: EVERY
155
+ * observation in the window predates the edit. One post-edit observation and the agent is already
156
+ * looking at the code it wrote, so the label would be noise.
157
+ */
117
158
  export declare function findContradictions(allEvents: readonly ReticleEvent[], options?: ContradictionOptions): Contradiction[];
@@ -1,4 +1,5 @@
1
- import { ContradictionKind, EventType, MUTATING_METHODS, isDevToolingUrl, } from '@reticlehq/core';
1
+ import { ContradictionKind, EventType, MUTATING_METHODS, isDevToolingUrl, isSameDocument, isSameEditEpoch, } from '@reticlehq/core';
2
+ import { describeSuperseded } from './observed-in-window.js';
2
3
  import { findStaleResponses } from './stale-response.js';
3
4
  import { findBodyFailures } from './body-failures.js';
4
5
  import { findEchoMismatches } from './echo-mismatch.js';
@@ -162,9 +163,64 @@ function splitDevTooling(events) {
162
163
  });
163
164
  return { app, ignored };
164
165
  }
166
+ /**
167
+ * Cross-channel contradictions in this window, with the edit-epoch caveat attached when it applies.
168
+ *
169
+ * Cross-epoch evidence is LABELLED rather than excluded, which is the opposite of what the document
170
+ * scoping does, and the difference is the point. A navigation is total — it throws away the page,
171
+ * the refs, the in-flight requests and the state — so nothing recorded before it is still about the
172
+ * world, and dropping it is the only honest option. A hot update is not: most modules, most of the
173
+ * DOM, the whole network log and every console line survive one, so most of what was observed a
174
+ * second before an edit is still true a second after. Excluding it would empty windows that hold
175
+ * real findings, and an emptied window reads as "nothing happened" — which is the more expensive of
176
+ * the two wrong answers and the one this whole family of checks exists to prevent.
177
+ *
178
+ * So the findings stand and the caveat is said out loud, and only when it is unambiguous: EVERY
179
+ * observation in the window predates the edit. One post-edit observation and the agent is already
180
+ * looking at the code it wrote, so the label would be noise.
181
+ */
165
182
  export function findContradictions(allEvents, options = {}) {
183
+ const found = findWindowContradictions(allEvents, options);
184
+ const predates = allEvents.length > 0 &&
185
+ allEvents.every((e) => !isSameEditEpoch(e.editEpoch, options.currentEditEpoch));
186
+ if (!predates)
187
+ return found;
188
+ // Prepended, not appended: the caveat governs how everything under it should be read, and it has
189
+ // to survive the rules that return early with a single finding of their own.
190
+ return [
191
+ {
192
+ kind: ContradictionKind.EVIDENCE_PREDATES_EDIT,
193
+ claim: 'these observations describe the code as it is now',
194
+ counter: 'every one of them was recorded before the last hot update landed in the page',
195
+ detail: 'the source changed and the page re-rendered after this evidence was captured, so it describes code that has since been replaced — nothing here is necessarily wrong, but nothing here has seen the edit either. Drive the app again to verify the current code',
196
+ },
197
+ ...found,
198
+ ];
199
+ }
200
+ function findWindowContradictions(allEvents, options) {
166
201
  const found = [];
167
- const { app: events, ignored: ignoredDevTooling } = splitDevTooling(allEvents);
202
+ const { app: allApp, ignored: ignoredDevTooling } = splitDevTooling(allEvents);
203
+ // ── Evidence belonging to a document that has since been replaced ───────────────────────────
204
+ // Scoped ONCE, here, for the same reason the dev-tooling split is: every rule below asks "what
205
+ // else was in this window", and answering that with a dead page's traffic is a defect in all of
206
+ // them rather than in whichever one reported it. Applied after the dev-tooling split so the count
207
+ // reported below is the app's own evidence and not the toolchain's noise.
208
+ const events = allApp.filter((e) => isSameDocument(e.documentId, options.currentDocumentId));
209
+ const superseded = allApp.length - events.length;
210
+ // An empty window was always allowed to mean "nothing happened"; that reading is only unsafe once
211
+ // supersession is what emptied it. Reported ALONE and before every rule below, because a window
212
+ // with nothing left in it is exactly the shape `action-had-no-effect` fires on — so without this
213
+ // the fix would have swapped a wrong citation for a wrong accusation.
214
+ if (superseded > 0 && 0 === events.length) {
215
+ return [
216
+ {
217
+ kind: ContradictionKind.EVIDENCE_SUPERSEDED,
218
+ claim: 'this window holds observations that could answer for the action',
219
+ counter: describeSuperseded('observations', superseded),
220
+ detail: 'a full navigation or a reload built a new document, and everything recorded here belongs to the old one — citing it would name requests, errors and state that no longer describe anything on screen',
221
+ },
222
+ ];
223
+ }
168
224
  const settled = events.filter((e) => e.type === EventType.NET_REQUEST).map(netCall);
169
225
  // ── Overlapping reads that settled out of order ─────────────────────────────────────────────
170
226
  // Independent of any action, so it runs on every window: the race is a property of the timeline.
@@ -290,18 +346,71 @@ export function findContradictions(allEvents, options = {}) {
290
346
  if (!advanced) {
291
347
  const ignoredWrites = settled.filter((c) => true === c.ok && isMutating(c));
292
348
  if (ignoredWrites.length > 0) {
293
- found.push({
294
- kind: ContradictionKind.RESPONSE_IGNORED,
295
- claim: `${String(ignoredWrites.length)} write(s) succeeded on the server`,
296
- counter: 'nothing on the client changed — no DOM, store or route movement',
297
- detail: ignoredWrites.map(describe).join('; '),
298
- });
349
+ // ...unless THIS document handed the consequence to another browsing context. An OAuth sign-in
350
+ // posts, succeeds, and continues in a popup the in-page SDK cannot follow (#508): the original
351
+ // tab legitimately never changes, and response-ignored would accuse it of ignoring a response
352
+ // it handed off. The opened-context event flips the reading from "the client did nothing" to
353
+ // "the client went where we cannot look". Scoped like every rule here to the attribution floor
354
+ // (`options.actionSince`; the local below is declared later, for the window rules).
355
+ const contextFloor = options.actionSince;
356
+ const openedContext = events.some((e) => e.type === EventType.CONTEXT_OPENED && contextFloor !== undefined && e.t >= contextFloor);
357
+ found.push(openedContext
358
+ ? {
359
+ kind: ContradictionKind.CONSEQUENCE_ELSEWHERE,
360
+ claim: `${String(ignoredWrites.length)} write(s) succeeded on the server`,
361
+ counter: 'this document never changed because the page opened another browsing context during this window (e.g. an OAuth popup), where the in-page SDK cannot observe the result',
362
+ detail: ignoredWrites.map(describe).join('; '),
363
+ }
364
+ : {
365
+ kind: ContradictionKind.RESPONSE_IGNORED,
366
+ claim: `${String(ignoredWrites.length)} write(s) succeeded on the server`,
367
+ counter: 'nothing on the client changed — no DOM, store or route movement',
368
+ detail: ignoredWrites.map(describe).join('; '),
369
+ });
299
370
  }
300
371
  }
372
+ // ── The app announced a consequence and nothing else moved ─────────────────────────────────
373
+ // Scoped as tightly as the evidence allows, because this rule fires on the ABSENCE of everything
374
+ // else and that is the easiest way to build a false positive.
375
+ //
376
+ // Only when the window is otherwise EMPTY: no DOM, no store, no route (`!advanced`) and no request
377
+ // at all. A request means the app reached for something, and whether it settled, failed or was
378
+ // ignored belongs to three other rules — firing here too would report one fact twice.
379
+ //
380
+ // `successSignals` reuses the failure-shaped filter above: an app that announced `deploy:failed`
381
+ // is correctly reporting that nothing happened, and accusing it inverts the meaning of the one app
382
+ // doing this right.
383
+ //
384
+ // And only for a window attributed to an ACTION. `reticle_assert` OBSERVES — there is no click
385
+ // whose consequence should have corroborated anything, so an assert over a quiet window carrying
386
+ // one signal is an ordinary read, not a claim nothing backs. Without this the rule reddened eight
387
+ // existing tests that assert exactly that, which is the false-positive class this scoping exists
388
+ // to prevent.
389
+ if (options.actionSince !== undefined &&
390
+ !advanced &&
391
+ 0 === settled.length &&
392
+ successSignals.length > 0) {
393
+ found.push({
394
+ kind: ContradictionKind.SIGNAL_WITHOUT_CONSEQUENCE,
395
+ claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
396
+ counter: 'nothing else in the window moved — no DOM, no store, no route, no request — so the only ' +
397
+ 'evidence that anything happened is the app saying so',
398
+ detail: 'a signal emitted from the value the app was ASKED for, rather than the one it committed, ' +
399
+ 'reads identically to one that worked',
400
+ });
401
+ }
301
402
  // ── The same write fired more than once, inside ONE action's window ─────────────────────────
302
403
  // Attribution is the whole rule here, not a refinement of it: counting `method + url` over whatever
303
404
  // window the caller handed in turns two legitimate separate saves into a double submit. See
304
405
  // ContradictionOptions.actionSince.
406
+ //
407
+ // A captured request body joins the identity. A command-bus API posts every mutation to one URL
408
+ // and discriminates on a body field (`{"command":"study.stage.set"}` vs `{"command":"mesh.plan"}`),
409
+ // so under URL alone each of those distinct writes read as a repeat of the first, and clean
410
+ // verdicts degraded to unknown behind doubles that were never doubles. When no body was captured
411
+ // (capture off, or a non-text body, which reports a type marker instead) there is nothing to
412
+ // compare and the URL alone stands, as before; a window where only some calls carried bodies
413
+ // compares nothing rather than guess.
305
414
  const actionSince = options.actionSince;
306
415
  if (actionSince !== undefined) {
307
416
  const writeCounts = new Map();
@@ -311,17 +420,21 @@ export function findContradictions(allEvents, options = {}) {
311
420
  const call = netCall(event);
312
421
  if (!isMutating(call))
313
422
  continue;
314
- const key = `${call.method} ${call.url}`;
315
- writeCounts.set(key, (writeCounts.get(key) ?? 0) + 1);
423
+ const label = `${call.method} ${call.url}`;
424
+ const body = asString(event.data['requestBody']);
425
+ const key = body === undefined || 0 === body.length ? label : `${label} ${body}`;
426
+ const entry = writeCounts.get(key) ?? { label, count: 0 };
427
+ entry.count += 1;
428
+ writeCounts.set(key, entry);
316
429
  }
317
- for (const [key, count] of writeCounts) {
430
+ for (const [, { label, count }] of writeCounts) {
318
431
  if (count < 2)
319
432
  continue;
320
433
  found.push({
321
434
  kind: ContradictionKind.DUPLICATE_REQUEST,
322
435
  claim: 'one user action was performed',
323
436
  counter: `the same write fired ${String(count)} times`,
324
- detail: `${key} ×${String(count)}`,
437
+ detail: `${label} ×${String(count)}`,
325
438
  });
326
439
  }
327
440
  }
@@ -31,6 +31,20 @@ export interface DeclaredExpectations {
31
31
  rendersContent: boolean;
32
32
  }
33
33
  export declare function declaredExpectations(predicate: Predicate | undefined): DeclaredExpectations;
34
+ /**
35
+ * Did the caller name a consequence that does not depend on the response body?
36
+ *
37
+ * The unread-body clause exists for the case where the body is the ONLY channel that could have
38
+ * contradicted the screen — a 200 with per-item failures inside, a GraphQL error that is also a
39
+ * 200. A declared string on screen, store path, or signal that held is a different channel, and
40
+ * grading `unknown` there costs a real verdict (measured: a 201 plus the unique row, unread body,
41
+ * agent went to enable capture instead of finishing the drive).
42
+ *
43
+ * Same conservatism as `declaredExpectations`: only the top level and `allOf`. An `anyOf` branch
44
+ * may never have held, and honouring it would skip the unread caveat on the strength of a net
45
+ * success that was the branch that actually matched.
46
+ */
47
+ export declare function declaresBodyIndependentChannel(predicate: Predicate | undefined): boolean;
34
48
  /**
35
49
  * Does a call the window recorded match a failure the caller declared?
36
50
  *
@@ -53,6 +53,39 @@ export function declaredExpectations(predicate) {
53
53
  walk(predicate);
54
54
  return { netFailures, rendersContent };
55
55
  }
56
+ /**
57
+ * Did the caller name a consequence that does not depend on the response body?
58
+ *
59
+ * The unread-body clause exists for the case where the body is the ONLY channel that could have
60
+ * contradicted the screen — a 200 with per-item failures inside, a GraphQL error that is also a
61
+ * 200. A declared string on screen, store path, or signal that held is a different channel, and
62
+ * grading `unknown` there costs a real verdict (measured: a 201 plus the unique row, unread body,
63
+ * agent went to enable capture instead of finishing the drive).
64
+ *
65
+ * Same conservatism as `declaredExpectations`: only the top level and `allOf`. An `anyOf` branch
66
+ * may never have held, and honouring it would skip the unread caveat on the strength of a net
67
+ * success that was the branch that actually matched.
68
+ */
69
+ export function declaresBodyIndependentChannel(predicate) {
70
+ if (predicate === undefined)
71
+ return false;
72
+ const walk = (p) => {
73
+ switch (p.kind) {
74
+ case PredicateKind.ALL_OF:
75
+ return p.predicates.some(walk);
76
+ case PredicateKind.TEXT:
77
+ return true !== p.absent;
78
+ case PredicateKind.SIGNAL:
79
+ case PredicateKind.STATE:
80
+ return true;
81
+ case PredicateKind.ELEMENT:
82
+ return true !== p.absent && (p.query.value !== undefined || p.query.text !== undefined);
83
+ default:
84
+ return false;
85
+ }
86
+ };
87
+ return walk(predicate);
88
+ }
56
89
  /**
57
90
  * Does a call the window recorded match a failure the caller declared?
58
91
  *
@@ -133,6 +133,20 @@ export function findEchoMismatches(events, actionSince) {
133
133
  continue;
134
134
  const echoed = scalarsByKey(response);
135
135
  const asked = scalarsByKey(request);
136
+ // The response has to be ECHO-SHAPED before any key can contradict anything. An echo restates
137
+ // what was sent, so most of the request's scalar keys reappear somewhere in it; a snapshot that
138
+ // happens to share a key or two does not (#506: `{"command":"chat.send"}` answered with the
139
+ // viewer snapshot read one incidental shared key as an attempted echo of a write that fully
140
+ // applied). Requiring a strict majority of the request's keys to appear keeps every documented
141
+ // true positive — an envelope restates most of its fields — and deliberately trades the rare
142
+ // half-echoed write away, the same direction MAX_ECHO_KEYS already trades in.
143
+ let shared = 0;
144
+ for (const key of asked.keys()) {
145
+ if (echoed.has(key))
146
+ shared += 1;
147
+ }
148
+ if (shared * 2 <= asked.size)
149
+ continue;
136
150
  const dropped = [];
137
151
  for (const [key, wanted] of asked) {
138
152
  // More than one requested value for a key (a before/after pair, a list of items) makes "what
@@ -10,3 +10,15 @@
10
10
  * not do what you thought) versus things happened but not that one (you named it wrong).
11
11
  */
12
12
  export declare function describeObserved(noun: string, seen: readonly string[]): string;
13
+ /**
14
+ * The third answer, and the one this file existed to make room for: things happened, and they belong
15
+ * to a document that has since been replaced.
16
+ *
17
+ * `describeObserved` already separates "nothing happened" from "things happened but not that one",
18
+ * because those need different fixes. A window whose evidence was discarded as superseded is a third
19
+ * case needing a third fix, and it is the one that reads most like the first: everything that could
20
+ * have answered the question is gone, so a bare "nothing happened in this window" is available and
21
+ * wrong. The remedy is in the sentence, because an agent that cannot act on a caveat learns to skip
22
+ * it — here the remedy is to drive the action again against the page now on screen.
23
+ */
24
+ export declare function describeSuperseded(noun: string, count: number): string;
@@ -19,3 +19,20 @@ export function describeObserved(noun, seen) {
19
19
  const rest = unique.length - shown.length;
20
20
  return `${noun} seen in this window: ${shown.join(', ')}${rest > 0 ? `, and ${String(rest)} more` : ''}`;
21
21
  }
22
+ /**
23
+ * The third answer, and the one this file existed to make room for: things happened, and they belong
24
+ * to a document that has since been replaced.
25
+ *
26
+ * `describeObserved` already separates "nothing happened" from "things happened but not that one",
27
+ * because those need different fixes. A window whose evidence was discarded as superseded is a third
28
+ * case needing a third fix, and it is the one that reads most like the first: everything that could
29
+ * have answered the question is gone, so a bare "nothing happened in this window" is available and
30
+ * wrong. The remedy is in the sentence, because an agent that cannot act on a caveat learns to skip
31
+ * it — here the remedy is to drive the action again against the page now on screen.
32
+ */
33
+ export function describeSuperseded(noun, count) {
34
+ return (`no ${noun} belong to the document currently on screen: ${String(count)} ${noun} were observed ` +
35
+ `and every one of them belongs to a document that has since been replaced (the page reloaded or ` +
36
+ `navigated). This says nothing about the app — the evidence went away with the page that produced ` +
37
+ `it. Re-drive the action against the page now on screen`);
38
+ }
@@ -0,0 +1,30 @@
1
+ import type { Predicate } from './predicate-schema.js';
2
+ /**
3
+ * Did the caller's predicate ask about registered state?
4
+ *
5
+ * Walks composites, because `allOf[{net}, {state}]` asks about state exactly as much as a bare
6
+ * `{state}` does — and the gap that reveals is the same one either way.
7
+ *
8
+ * Lives here rather than at a call site because both the act path and the assert path need it, and
9
+ * two walkers over one predicate tree is two chances to disagree about what counts as asking.
10
+ *
11
+ * NOT in `predicate-schema.ts` next to `predicateFieldsFor`, where it would otherwise belong: five
12
+ * open contributor PRs are queued on that file and on `predicate-eval.ts`, and a one-function import
13
+ * is not worth handing five people a conflict. Named `predicate-asks` rather than `predicate-shape`
14
+ * because `predicate-shape.test.ts` already exists and is about something else entirely — a file
15
+ * that reads as the subject of an unrelated test is a half-hour somebody loses later.
16
+ */
17
+ export declare function declaresState(predicate: Predicate): boolean;
18
+ /**
19
+ * Does the caller's predicate judge the DOM — an `element` or `text` clause anywhere in the tree?
20
+ *
21
+ * The question behind it is where a verdict is allowed to point. A DOM predicate has its own locus:
22
+ * the element it matched, whose `file:line` the descriptor already carries. A `signal`/`net`/`state`
23
+ * failure has none — there is no node for "the request was never made" — and that is the one case
24
+ * where the verdict borrows the last driven control's line, because the handler that should have
25
+ * fired lives there.
26
+ *
27
+ * Walks composites for the same reason `declaresState` does: `allOf[{element}, {signal}]` looked at
28
+ * the DOM exactly as much as a bare `{element}` did.
29
+ */
30
+ export declare function declaresDom(predicate: Predicate): boolean;
@@ -0,0 +1,48 @@
1
+ import { PredicateKind } from '@reticlehq/core';
2
+ /**
3
+ * Did the caller's predicate ask about registered state?
4
+ *
5
+ * Walks composites, because `allOf[{net}, {state}]` asks about state exactly as much as a bare
6
+ * `{state}` does — and the gap that reveals is the same one either way.
7
+ *
8
+ * Lives here rather than at a call site because both the act path and the assert path need it, and
9
+ * two walkers over one predicate tree is two chances to disagree about what counts as asking.
10
+ *
11
+ * NOT in `predicate-schema.ts` next to `predicateFieldsFor`, where it would otherwise belong: five
12
+ * open contributor PRs are queued on that file and on `predicate-eval.ts`, and a one-function import
13
+ * is not worth handing five people a conflict. Named `predicate-asks` rather than `predicate-shape`
14
+ * because `predicate-shape.test.ts` already exists and is about something else entirely — a file
15
+ * that reads as the subject of an unrelated test is a half-hour somebody loses later.
16
+ */
17
+ export function declaresState(predicate) {
18
+ if (predicate.kind === PredicateKind.STATE)
19
+ return true;
20
+ if (predicate.kind === PredicateKind.ALL_OF || predicate.kind === PredicateKind.ANY_OF) {
21
+ return predicate.predicates.some(declaresState);
22
+ }
23
+ if (predicate.kind === PredicateKind.NOT)
24
+ return declaresState(predicate.predicate);
25
+ return false;
26
+ }
27
+ /**
28
+ * Does the caller's predicate judge the DOM — an `element` or `text` clause anywhere in the tree?
29
+ *
30
+ * The question behind it is where a verdict is allowed to point. A DOM predicate has its own locus:
31
+ * the element it matched, whose `file:line` the descriptor already carries. A `signal`/`net`/`state`
32
+ * failure has none — there is no node for "the request was never made" — and that is the one case
33
+ * where the verdict borrows the last driven control's line, because the handler that should have
34
+ * fired lives there.
35
+ *
36
+ * Walks composites for the same reason `declaresState` does: `allOf[{element}, {signal}]` looked at
37
+ * the DOM exactly as much as a bare `{element}` did.
38
+ */
39
+ export function declaresDom(predicate) {
40
+ if (predicate.kind === PredicateKind.ELEMENT || predicate.kind === PredicateKind.TEXT)
41
+ return true;
42
+ if (predicate.kind === PredicateKind.ALL_OF || predicate.kind === PredicateKind.ANY_OF) {
43
+ return predicate.predicates.some(declaresDom);
44
+ }
45
+ if (predicate.kind === PredicateKind.NOT)
46
+ return declaresDom(predicate.predicate);
47
+ return false;
48
+ }