@reticlehq/server 2.9.0 → 2.10.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.
- package/SKILL.md +17 -17
- package/dist/capsule/capsule-store.d.ts +2 -2
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-verify.js +1 -1
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +19 -1
- package/dist/events/contradictions.js +17 -2
- package/dist/events/echo-mismatch.d.ts +2 -2
- package/dist/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- package/dist/flows/flow-tools.js +1 -1
- package/dist/flows/verify-change-tools.js +1 -1
- package/dist/honesty/verified.js +16 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/init/mcp.d.ts +1 -2
- package/dist/init/mcp.js +4 -1
- package/dist/license/license.d.ts +69 -4
- package/dist/license/license.js +84 -15
- package/dist/mcp/mcp-proxy.d.ts +2 -27
- package/dist/mcp/mcp-proxy.js +4 -88
- package/dist/mcp/mcp.d.ts +18 -2
- package/dist/mcp/mcp.js +31 -10
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.js +1 -1
- package/dist/mcp/server-instructions.js +2 -2
- package/dist/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- package/dist/session/no-session-diagnosis.d.ts +7 -0
- package/dist/session/no-session-diagnosis.js +23 -2
- package/dist/session/no-session-watch.js +7 -0
- package/dist/session/session-readiness.js +1 -1
- package/dist/telemetry/daemon-telemetry.js +7 -2
- package/dist/telemetry/instrumentation-stall.d.ts +7 -0
- package/dist/telemetry/instrumentation-stall.js +13 -0
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- package/dist/telemetry/telemetry.js +10 -1
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.js +1 -1
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +10 -5
- package/dist/tools/tool-kit.d.ts +30 -3
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.js +2 -3
- package/dist/tools/tools.js +16 -1
- package/docs/agent-cheatsheet.md +2 -2
- package/docs/capabilities.mdx +4 -4
- package/docs/cli/hunt.mdx +2 -2
- package/docs/deploy-checks.md +2 -2
- package/docs/docs.json +1 -1
- package/docs/enterprise.md +7 -3
- package/docs/flows.md +4 -4
- package/docs/gates.md +1 -1
- package/docs/recipes.mdx +2 -2
- package/docs/telemetry-contract.md +17 -0
- package/docs/tools-overview.mdx +1 -1
- package/docs/tools-session-and-feedback.mdx +5 -0
- package/docs/tools-tools-and-run.mdx +15 -15
- package/docs/usage.md +3 -3
- package/package.json +2 -2
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Reticle
|
|
2
2
|
|
|
3
|
-
Reticle embeds a dev-only SDK in the user's running app and exposes it to you as `reticle_*` MCP tools. You look, act, observe, and assert against the real app. No screenshots, and no browser download for the verify loop
|
|
3
|
+
Reticle embeds a dev-only SDK in the user's running app and exposes it to you as `reticle_*` MCP tools. You look, act, observe, and assert against the real app. No screenshots, and no browser download for the verify loop: it drives the tab the user already has open.
|
|
4
4
|
|
|
5
5
|
This file is the whole critical path and nothing else. Everything it leaves out is at `https://docs.reticle.sh`, one page at a time.
|
|
6
6
|
|
|
@@ -84,7 +84,7 @@ It detects the framework, package manager and UI library, registers the MCP serv
|
|
|
84
84
|
|
|
85
85
|
**Never ask about the port.** There are two, and conflating them is a top setup failure. The dev-server port (3000, 5173) belongs to the project's own dev script and the daemon never binds it. The bridge port (**4400**) is the daemon-to-SDK channel and defaults correctly.
|
|
86
86
|
|
|
87
|
-
Read the report: `✓` applied, `·` already wired, `–` skipped, `ℹ` done but incomplete in a way that matters, `⚠` needs your edit. **`⚠` and `ℹ` both need you.** `ℹ` is the one people skim past: the step ran, and something about the result still stops a session appearing. Each line carries the exact snippet. A non-zero exit is a to-do list, not a failed install. Fix every `⚠` before moving on
|
|
87
|
+
Read the report: `✓` applied, `·` already wired, `–` skipped, `ℹ` done but incomplete in a way that matters, `⚠` needs your edit. **`⚠` and `ℹ` both need you.** `ℹ` is the one people skim past: the step ran, and something about the result still stops a session appearing. Each line carries the exact snippet. A non-zero exit is a to-do list, not a failed install. Fix every `⚠` before moving on. Per-framework wiring is at `https://docs.reticle.sh/install-manual.md`.
|
|
88
88
|
|
|
89
89
|
## 2. Register the MCP server, then restart the client.
|
|
90
90
|
|
|
@@ -109,7 +109,7 @@ This is the step the funnel dies on. The daemon runs, the MCP server registers,
|
|
|
109
109
|
- **Vite + React**: `reticle()` is in the `plugins` array of `vite.config.*`.
|
|
110
110
|
- **Next.js**: `withReticle` wraps the export in `next.config.*`, `reticle-dev.tsx` exists, and it is mounted in the root layout or `_app`.
|
|
111
111
|
- **Remix**, **Astro**: wired by `init`, each with an app in this repo that a gate drives.
|
|
112
|
-
- **Anything else**: nothing is wired. Do it by hand
|
|
112
|
+
- **Anything else**: nothing is wired. Do it by hand: fetch `https://docs.reticle.sh/frameworks.md`.
|
|
113
113
|
|
|
114
114
|
Those four are the frameworks with an app and a CI gate behind them. The SDK is framework-agnostic and usually connects elsewhere, but say so honestly rather than reporting a proven install.
|
|
115
115
|
|
|
@@ -120,7 +120,7 @@ Two rules that cause silent failures if broken:
|
|
|
120
120
|
|
|
121
121
|
Then make sure something is serving the app.
|
|
122
122
|
|
|
123
|
-
**If a dev server is already listening, use it. If none is, start one yourself
|
|
123
|
+
**If a dev server is already listening, use it. If none is, start one yourself**: read the project's own dev script out of `package.json` (`dev`, `start`, whatever this project calls it), run it in the BACKGROUND, and tell the user in one line that it is running and how to stop it. Stopping here to ask is how a setup turn ends with nothing verified.
|
|
124
124
|
|
|
125
125
|
The daemon deliberately will not do this for you. A build process started by a long-lived background daemon is invisible to the person whose machine it runs on and orphans when the daemon exits; a dev server YOU start is in the transcript, attributable, and stoppable.
|
|
126
126
|
|
|
@@ -128,7 +128,7 @@ Five guards, none optional:
|
|
|
128
128
|
|
|
129
129
|
1. **Never start a second one.** If something is already listening on the app's port, use it.
|
|
130
130
|
2. **Never guess the command.** It comes from `package.json` scripts. No recognisable dev script means say so and stop, not invent one.
|
|
131
|
-
3. **Never kill anything.** Not a dev server, not a daemon, not a port holder
|
|
131
|
+
3. **Never kill anything.** Not a dev server, not a daemon, not a port holder, including one you started.
|
|
132
132
|
4. **Background it, and say so.** A dev server the human does not know about is the same failure one step later.
|
|
133
133
|
5. **The permission prompt belongs to your host.** Never bypass, suppress or auto-approve it, and take a refusal as the answer.
|
|
134
134
|
|
|
@@ -142,7 +142,7 @@ reticle_sessions()
|
|
|
142
142
|
|
|
143
143
|
You need a session whose URL matches the app's localhost address. **Nothing below this line is meaningful until you have one, and you may not report setup complete without one.**
|
|
144
144
|
|
|
145
|
-
**Empty list?** Read `next_action` first, then `why`. `next_action` is the machine-readable half: it names which of the four cases this is and, when there is one, the literal command to run and the port
|
|
145
|
+
**Empty list?** Read `next_action` first, then `why`. `next_action` is the machine-readable half: it names which of the four cases this is and, when there is one, the literal command to run and the port, sourced from this project's own scripts, never guessed. `why` is the same thing in prose for the human. The daemon can see whether a session was ever here and whether a dev server is listening. Then, in order: is the SDK imported and called in the app entry, is the dev server actually serving that entry, is the connect guarded on `hostname === 'localhost'`, and is the bridge port the same number on both sides. **Nothing listening at all? That one is yours: start it, per step 3.** If something IS listening, do not tell the user to start what they are already running; the fault is the SDK not loading in the page. Full checklist: `https://docs.reticle.sh/troubleshooting.md`.
|
|
146
146
|
|
|
147
147
|
## 5. Drive one real flow and produce a verdict.
|
|
148
148
|
|
|
@@ -152,10 +152,10 @@ A connected session is not a result. The user has installed something and seen n
|
|
|
152
152
|
|
|
153
153
|
Tell the user to keep the tab visible. The HUD is on by default (glow border, animated cursor, narration per step) and watching you drive their own app is the demo.
|
|
154
154
|
|
|
155
|
-
Drive it in as few calls as you can. Every call is a full model turn, and in a client that asks the user to approve each one it is also a click
|
|
155
|
+
Drive it in as few calls as you can. Every call is a full model turn, and in a client that asks the user to approve each one it is also a click. A flow driven one call at a time is how a person gives up before they ever see a verdict.
|
|
156
156
|
|
|
157
157
|
1. `reticle_snapshot({ mode: "interactive" })` **once**, for the whole flow. Not once per step.
|
|
158
|
-
2. `reticle_act_sequence` for the setup
|
|
158
|
+
2. `reticle_act_sequence` for the setup: every fill and every intermediate click in ONE call.
|
|
159
159
|
3. `reticle_act_and_wait({ ref, action, until })` for the final step only. This is the call that produces the verdict, and `until` names the expected consequence before the action fires.
|
|
160
160
|
4. `reticle_state()` once at the end.
|
|
161
161
|
|
|
@@ -170,7 +170,7 @@ reticle_run({ tool: "reticle_record", args: { action: "stop", recordingName: "<
|
|
|
170
170
|
reticle_run({ tool: "reticle_flow_save", args: { flowName: "<flow>" } })
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
-
`reticle_flow_save` returns `assertions.grade`. If it is not `asserted`, the flow only clicks
|
|
173
|
+
`reticle_flow_save` returns `assertions.grade`. If it is not `asserted`, the flow only clicks: it will pass even when the feature is broken. Say that in one line rather than presenting it as a regression check.
|
|
174
174
|
|
|
175
175
|
Tell the user plainly: that flow is now saved to `.reticle/flows/`, and re-verifying it after any future change is one call with no model in the loop.
|
|
176
176
|
|
|
@@ -178,7 +178,7 @@ Tell the user plainly: that flow is now saved to `.reticle/flows/`, and re-verif
|
|
|
178
178
|
|
|
179
179
|
`init` writes a managed block into the project's `CLAUDE.md` and `AGENTS.md` (and `.cursor/rules/reticle.mdc` where it applies), plus a `RETICLE.md` reference file and the `/reticle` command. That block is what makes the next agent verify its own work instead of guessing.
|
|
180
180
|
|
|
181
|
-
Confirm the block is present, between `<!-- reticle:begin ... -->` and `<!-- reticle:end -->`. **Do not hand-write your own copy
|
|
181
|
+
Confirm the block is present, between `<!-- reticle:begin ... -->` and `<!-- reticle:end -->`. **Do not hand-write your own copy.** The markers are how a re-run stays idempotent, and a second unmarked copy will never be updated again. If it is missing, re-run `init`.
|
|
182
182
|
|
|
183
183
|
**Only after step 5 has produced a verdict**, report what you drove and what it produced, then:
|
|
184
184
|
|
|
@@ -194,7 +194,7 @@ Stop here. Do not continue into VERIFY.
|
|
|
194
194
|
|
|
195
195
|
**Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** Everything else (`act`, `snapshot`, `query`, `navigate`, `observe`, `network`, `console`) moves or reads the app and proves nothing. A drive that ends without one of those two has no result, however many tools it used.
|
|
196
196
|
|
|
197
|
-
A verdict of `verified: "unknown"` is not a pass. It means Reticle drove the app and could not tell what happened. Report it as unknown. **Never weaken a check to make it pass.**
|
|
197
|
+
A verdict of `verified: "unknown"` is not a pass. It means Reticle drove the app and could not tell what happened. Report it as unknown. `verified: "no-fault"` is not a pass either. It means the page settled and no channel reported a problem, but nothing was declared to prove, so assert a consequence the action CHANGES. **Never weaken a check to make it pass.**
|
|
198
198
|
|
|
199
199
|
## Take the cheapest path that answers the question
|
|
200
200
|
|
|
@@ -202,14 +202,14 @@ Work down this list and stop at the first row that fits. Do not hand-drive a flo
|
|
|
202
202
|
|
|
203
203
|
| The question | The call | Calls |
|
|
204
204
|
| --- | --- | --- |
|
|
205
|
-
| "Did my edit break anything?" | `reticle_run({ tool: "
|
|
205
|
+
| "Did my edit break anything?" | `reticle_run({ tool: "reticle_verify", args: { action: "change", files: ["src/App.tsx"] } })` | 1 |
|
|
206
206
|
| "Does this known journey still work?" | `reticle_run({ tool: "reticle_flow_replay", args: { flowName: "login" } })` | 1 |
|
|
207
207
|
| "Does this new behaviour work?" | `reticle_act_sequence` for the setup, then ONE `reticle_act_and_wait` | 2 |
|
|
208
208
|
| No MCP available at all | `npx @reticlehq/server verify <url>` in the shell | 1, no MCP |
|
|
209
209
|
|
|
210
|
-
`
|
|
210
|
+
`reticle_verify` and `reticle_flow_replay` are **not on the advertised tool list**: they are reached through `reticle_run` exactly as written above. That is the supported call shape, not a workaround, and it is why you have to be told they exist at all.
|
|
211
211
|
|
|
212
|
-
`
|
|
212
|
+
`reticle_verify {action:"change"}` answers `unknown` when no saved flow covers the files you changed. That is the honest answer and not a failure. Nothing ran, so nothing was proved. It is also the signal to record one. Never read it as a pass.
|
|
213
213
|
|
|
214
214
|
## Record once, replay cheaply
|
|
215
215
|
|
|
@@ -222,7 +222,7 @@ reticle_run({ tool: "reticle_record", args: { action: "stop", recordingName: "c
|
|
|
222
222
|
reticle_run({ tool: "reticle_flow_save", args: { flowName: "checkout" } })
|
|
223
223
|
```
|
|
224
224
|
|
|
225
|
-
From then on that journey re-verifies in one call, deterministically, and `
|
|
225
|
+
From then on that journey re-verifies in one call, deterministically, and `reticle_verify {action:"change"}` can start answering `yes` or `no` for the files it touches instead of `unknown`.
|
|
226
226
|
|
|
227
227
|
Check `assertions.grade` on the save. Anything other than `asserted` means the flow only acts, so it will pass even if the feature breaks.
|
|
228
228
|
|
|
@@ -243,11 +243,11 @@ reticle_act_and_wait({ sessionId, ref, action: "click", until: { kind: "allOf",
|
|
|
243
243
|
|
|
244
244
|
Prefer `reticle_act_and_wait({ ref, action, until })`. It names the expected consequence **before** the action, which is the difference between a check and a rationalisation.
|
|
245
245
|
|
|
246
|
-
A verdict of `verified: "unknown"` is not a pass. It means Reticle drove the app and could not tell what happened. Report it as unknown. **Never weaken a check to make it pass.**
|
|
246
|
+
A verdict of `verified: "unknown"` is not a pass. It means Reticle drove the app and could not tell what happened. Report it as unknown. `verified: "no-fault"` is not a pass either. It means the page settled and no channel reported a problem, but nothing was declared to prove, so assert a consequence the action CHANGES. **Never weaken a check to make it pass.**
|
|
247
247
|
|
|
248
248
|
Then report what you drove, what it produced, and the `file:line` for anything broken.
|
|
249
249
|
|
|
250
|
-
The surface is deliberately small: `default` 18, `all`
|
|
250
|
+
The surface is deliberately small: `default` 18, `all` 28. Editors budget tools across every MCP server you have connected (Cursor allows 40 in total), so the count is capped rather than allowed to grow. `reticle_tools` loads the argument grammar for the rest on demand, and `reticle_run` invokes any of them by name. Nothing is unreachable; the cold tail just costs one discovery hop.
|
|
251
251
|
|
|
252
252
|
- Batching, regression suites, reading a verdict: `https://docs.reticle.sh/agent-cheatsheet.md`
|
|
253
253
|
- Every predicate and action: `https://docs.reticle.sh/predicates.md`, `https://docs.reticle.sh/actions.md`
|
|
@@ -31,19 +31,19 @@ export declare const CapsuleSchema: z.ZodObject<{
|
|
|
31
31
|
/** Minimal steps to reproduce (prefix-trimmed). */
|
|
32
32
|
steps: z.ZodArray<z.ZodType<FlowStep, z.ZodTypeDef, FlowStep>, "many">;
|
|
33
33
|
}, "strip", z.ZodTypeAny, {
|
|
34
|
+
expected: string;
|
|
34
35
|
origin: string;
|
|
35
36
|
version: 1;
|
|
36
37
|
id: string;
|
|
37
|
-
expected: string;
|
|
38
38
|
observed: string;
|
|
39
39
|
steps: FlowStep[];
|
|
40
40
|
createdAt: number;
|
|
41
41
|
flow?: string | undefined;
|
|
42
42
|
}, {
|
|
43
|
+
expected: string;
|
|
43
44
|
origin: string;
|
|
44
45
|
version: 1;
|
|
45
46
|
id: string;
|
|
46
|
-
expected: string;
|
|
47
47
|
observed: string;
|
|
48
48
|
steps: FlowStep[];
|
|
49
49
|
createdAt: number;
|
package/dist/cli/cli-parse.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { AGENT_FLAG, BUG_FLAG, FEEDBACK_KINDS, KIND_FLAG, RATING_FLAG, } from '.
|
|
|
13
13
|
* telling readers to do exactly that on 110 lines before it was caught, so the invocation now leads
|
|
14
14
|
* the usage block rather than being a footnote somewhere else.
|
|
15
15
|
*/
|
|
16
|
-
export declare const CLI_USAGE = "usage: npx @reticlehq/server <command> (or `reticle <command>` once the bin is on your PATH)\n\n reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] [--app <dir>] (wire Reticle into the project in this directory)\n --app picks WHICH app in a monorepo, when several are found\n --no-mcp skips MORE than the server registration: also the agent rule files\n (CLAUDE.md / AGENTS.md / .cursor) and the /reticle command, because all three\n only make sense once the tools are reachable.\n reticle serve [--port N] [--drive <url>] [--headless] [--http] [--http-port N] [--http-token T]\n reticle stop [--port N] [--quiet] (stop the daemon we started, by its recorded pid)\n reticle kill [--port N] [--force] (free the port by its LISTENER, never the agent's mcp proxy)\n reticle restart [--port N] [--force] (kill, then start a daemon and wait for a real bind)\n reticle status [--port N]\n reticle doctor [--port N] (one command to diagnose setup: Chromium, daemon, port)\n reticle open [url] [--port N] (show the app: reuse the connected tab, else open one)\n reticle verify <url> [--headed] [--timeout N] [--storage-state <file>] (one-shot: drive the URL, verify saved flows, exit 0=pass)\n reticle affected [--since <ref>] [file...] (which saved flows must re-verify for the changed files)\n reticle gate [--since <ref>] [file...] (exit non-zero unless passing artifacts cover the affected flows)\n reticle watch [url] (on save, report which saved flows must re-verify)\n reticle drive <url> [--headless] (foreground mode, for debugging)\n reticle mcp [--port N] [--drive <url>] [--headless] (MCP stdio proxy; auto-starts daemon if needed)\n reticle update (install the latest server version and restart)\n reticle rollback (restore the previous server version and restart)\n reticle license (show enterprise license status: active | eval | missing)\n reticle telemetry [status|enable|disable] (anonymous usage metrics; status shows what's sent + the policy)\n reticle feedback [--rating 1-5] [--bug] \"message\" (tell us what worked and what didn't; prints exactly what it sends)\n reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> \"message\"\n (agents: file from anywhere, including a setup that never finished)\n reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]\n (OPT-IN: tell us who you are, e.g. for support or an enterprise trial)\n\nCloud (link this project to Reticle Cloud; runs/flows recorded on the dashboard):\n reticle login --email <e> [--code <c>] [--org <n>] (sign in: mails a code, then exchanges it)\n reticle link [--project <name|id>] (bind this repo: mints a scoped key, writes .reticle/cloud.json)\n reticle whoami (who am I signed in as, and is this repo attached?)\n reticle project <ls|create <name>> (list or create cloud projects)\n reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]\n reticle push (send local run artifacts to the dashboard)\n reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)\n\n'drive' shows the browser; everything else is hidden (serve/mcp own the pool behind leases, replay\nand the spec runner: batch work). --headed opts any of them in, --headless hides drive, CI hides it.";
|
|
16
|
+
export declare const CLI_USAGE = "usage: npx @reticlehq/server <command> (or `reticle <command>` once the bin is on your PATH)\n\n reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] [--app <dir>] (wire Reticle into the project in this directory)\n --app picks WHICH app in a monorepo, when several are found\n --no-mcp skips MORE than the server registration: also the agent rule files\n (CLAUDE.md / AGENTS.md / .cursor) and the /reticle command, because all three\n only make sense once the tools are reachable.\n reticle serve [--port N] [--drive <url>] [--headless] [--http] [--http-port N] [--http-token T]\n reticle stop [--port N] [--quiet] (stop the daemon we started, by its recorded pid)\n reticle kill [--port N] [--force] (free the port by its LISTENER, never the agent's mcp proxy)\n reticle restart [--port N] [--force] (kill, then start a daemon and wait for a real bind)\n reticle status [--port N]\n reticle doctor [--port N] (one command to diagnose setup: Chromium, daemon, port)\n reticle open [url] [--port N] (show the app: reuse the connected tab, else open one)\n reticle verify <url> [--headed] [--timeout N] [--storage-state <file>] (one-shot: drive the URL, verify saved flows, exit 0=pass)\n reticle affected [--since <ref>] [file...] (which saved flows must re-verify for the changed files)\n reticle gate [--since <ref>] [file...] (exit non-zero unless passing artifacts cover the affected flows)\n reticle watch [url] (on save, report which saved flows must re-verify)\n reticle drive <url> [--headless] (foreground mode, for debugging)\n reticle mcp [--port N] [--drive <url>] [--headless] (MCP stdio proxy; auto-starts daemon if needed)\n reticle update (install the latest server version and restart)\n reticle rollback (restore the previous server version and restart)\n reticle license (show enterprise license status: active | eval | missing)\n reticle telemetry [status|enable|disable] (anonymous usage metrics; status shows what's sent + the policy)\n reticle feedback [--rating 1-5] [--bug] \"message\" (tell us what worked and what didn't; prints exactly what it sends)\n reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement|experience> \"message\"\n (agents: file from anywhere, including a setup that never finished)\n reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]\n (OPT-IN: tell us who you are, e.g. for support or an enterprise trial)\n\nCloud (link this project to Reticle Cloud; runs/flows recorded on the dashboard):\n reticle login --email <e> [--code <c>] [--org <n>] (sign in: mails a code, then exchanges it)\n reticle link [--project <name|id>] (bind this repo: mints a scoped key, writes .reticle/cloud.json)\n reticle whoami (who am I signed in as, and is this repo attached?)\n reticle project <ls|create <name>> (list or create cloud projects)\n reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]\n reticle push (send local run artifacts to the dashboard)\n reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)\n\n'drive' shows the browser; everything else is hidden (serve/mcp own the pool behind leases, replay\nand the spec runner: batch work). --headed opts any of them in, --headless hides drive, CI hides it.";
|
|
17
17
|
/** The `reticle telemetry` sub-actions. Bare `reticle telemetry` means `status`. */
|
|
18
18
|
export declare const TelemetryAction: {
|
|
19
19
|
readonly STATUS: "status";
|
package/dist/cli/cli-parse.js
CHANGED
|
@@ -40,7 +40,7 @@ export const CLI_USAGE = `usage: npx @reticlehq/server <command> (or \`reticl
|
|
|
40
40
|
reticle license (show enterprise license status: active | eval | missing)
|
|
41
41
|
reticle telemetry [status|enable|disable] (anonymous usage metrics; status shows what's sent + the policy)
|
|
42
42
|
reticle feedback [--rating 1-5] [--bug] "message" (tell us what worked and what didn't; prints exactly what it sends)
|
|
43
|
-
reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "message"
|
|
43
|
+
reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement|experience> "message"
|
|
44
44
|
(agents: file from anywhere, including a setup that never finished)
|
|
45
45
|
reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]
|
|
46
46
|
(OPT-IN: tell us who you are, e.g. for support or an enterprise trial)
|
package/dist/cli/cli-verify.js
CHANGED
|
@@ -226,7 +226,7 @@ export function portBusyMessage(port) {
|
|
|
226
226
|
return (`reticle verify needs port ${String(port)} — the port your app dials — and a Reticle daemon is ` +
|
|
227
227
|
'already listening on it. It did not start a second one.\n\n' +
|
|
228
228
|
' • If you have the Reticle tools, ask the daemon that is already running instead: ' +
|
|
229
|
-
'reticle_run { tool: "
|
|
229
|
+
'reticle_run { tool: "reticle_verify", args: { action: "change", files: ["..."] } }\n' +
|
|
230
230
|
` • Or stop it and re-run: npx @reticlehq/server stop --port ${String(port)}\n` +
|
|
231
231
|
` • Or run both on another port, if your app is configured for it: RETICLE_PORT=<port> ` +
|
|
232
232
|
'npx @reticlehq/server verify <url>');
|
package/dist/crawl/crawl.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type CommandResult, type ReticleEvent } from '@reticlehq/core';
|
|
2
2
|
/** The slice of Session the crawler needs — so tests inject a fake without a live browser. */
|
|
3
3
|
export interface CrawlSession {
|
|
4
4
|
command(name: string, args?: Record<string, unknown>): Promise<CommandResult>;
|
|
@@ -21,8 +21,13 @@ export interface CrawlAnomaly {
|
|
|
21
21
|
* disagreeing about the same click). The second kind is why an autonomous crawl is worth more
|
|
22
22
|
* than a human clicking the same buttons: a person sees only the screen, so a UI that advanced
|
|
23
23
|
* while its write failed looks like success to them and always will.
|
|
24
|
+
*
|
|
25
|
+
* `CrawlAnomalyKind` and `ContradictionKind` are what THIS package puts here, and both are still
|
|
26
|
+
* enforced at the emit sites below. The type is a plain string because a rule registered by a
|
|
27
|
+
* consumer contributes kinds this package has never heard of, and a closed union here would mean
|
|
28
|
+
* a consumer's private vocabulary had to ship in the free product to be reportable.
|
|
24
29
|
*/
|
|
25
|
-
kind:
|
|
30
|
+
kind: string;
|
|
26
31
|
/** The control that triggered it. */
|
|
27
32
|
ref: string;
|
|
28
33
|
desc: string;
|
package/dist/crawl/crawl.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { span } from '../trace.js';
|
|
2
|
-
import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind,
|
|
2
|
+
import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind, DANGEROUS_ACTION_CONFIRM_ARG, EventType, ReticleCommand, } from '@reticlehq/core';
|
|
3
3
|
import { crawlEmptyNote } from './crawl-empty.js';
|
|
4
4
|
import { parseInteractive, asRecord, asNumber, asString, sourceOf, } from '../tools/tools-helpers.js';
|
|
5
5
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
@@ -86,6 +86,37 @@ async function countRevealed(session, opts, before) {
|
|
|
86
86
|
const known = new Set(before.map((i) => (i.ref !== '' ? i.ref : i.desc)));
|
|
87
87
|
return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc)).length;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The confirmation half of the dead-control rule. Kept separate so the rule above reads as one
|
|
91
|
+
* question, and so the second click gets its own window: reusing the first would let the first
|
|
92
|
+
* click's own (absent) activity decide the second, which is not a second sample at all.
|
|
93
|
+
*
|
|
94
|
+
* `true` means "silent both times" — the only state the anomaly is reported in. Anything that goes
|
|
95
|
+
* wrong here returns `false`: a confirmation that cannot run is not evidence, and the direction that
|
|
96
|
+
* costs a missed finding is better than the one that invents one.
|
|
97
|
+
*/
|
|
98
|
+
async function stillSilent(session, item, settleMs, opts, sleep) {
|
|
99
|
+
const since = session.elapsed();
|
|
100
|
+
session.beginAction?.(ReticleTool.CRAWL, { ref: item.ref, action: ActionType.CLICK });
|
|
101
|
+
try {
|
|
102
|
+
const again = await session.command(ReticleCommand.ACT, {
|
|
103
|
+
ref: item.ref,
|
|
104
|
+
action: ActionType.CLICK,
|
|
105
|
+
args: true === opts.confirmDangerous ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
|
|
106
|
+
});
|
|
107
|
+
await sleep(settleMs);
|
|
108
|
+
// A control that has GONE (the first click removed it) is not a dead control — it did something.
|
|
109
|
+
if (!again.ok || false === asRecord(again.result)['dispatched'])
|
|
110
|
+
return false;
|
|
111
|
+
return !session.eventsSince(since).some(isActivity);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
session.finishAction?.();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
89
120
|
export async function crawl(session, opts, sleep) {
|
|
90
121
|
const maxSteps = opts.maxSteps ?? CRAWL_DEFAULTS.MAX_STEPS;
|
|
91
122
|
const settleMs = opts.settleMs ?? CRAWL_DEFAULTS.SETTLE_MS;
|
|
@@ -194,14 +225,26 @@ export async function crawl(session, opts, sleep) {
|
|
|
194
225
|
0 === errs.length &&
|
|
195
226
|
!events.some(isActivity) &&
|
|
196
227
|
!legitimatelyInert(item.desc)) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
228
|
+
// CONFIRM IT. One silent sample is a sample, not a fact, and this is the strongest claim the
|
|
229
|
+
// crawler makes — "your button does nothing" is the finding a reader acts on immediately.
|
|
230
|
+
//
|
|
231
|
+
// Measured on a fixture whose FIXED twin has a working primary CTA: the crawl reported it dead
|
|
232
|
+
// on 2 of 3 runs, always a control the drive had already clicked successfully moments earlier.
|
|
233
|
+
// A false positive in this tier is the most expensive kind there is — it is the tier a verdict
|
|
234
|
+
// is built from, and the twin column is the whole claim this product makes.
|
|
235
|
+
//
|
|
236
|
+
// A second click costs one round trip on the rare control that looked dead, and nothing at all
|
|
237
|
+
// on every control that did not. A genuinely dead control is silent twice; the flake is not.
|
|
238
|
+
if (await stillSilent(session, item, settleMs, opts, sleep)) {
|
|
239
|
+
counts.deadControls += 1;
|
|
240
|
+
anomalies.push({
|
|
241
|
+
kind: CrawlAnomalyKind.DEAD_CONTROL,
|
|
242
|
+
ref: item.ref,
|
|
243
|
+
desc: item.desc,
|
|
244
|
+
detail: 'clicked TWICE and the app did nothing either time (no DOM/network/route/signal change)',
|
|
245
|
+
...source,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
205
248
|
}
|
|
206
249
|
}
|
|
207
250
|
// One more look, AFTER the clicks. The enumeration above happened before any of them, so anything
|
package/dist/ee/audit-log.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* ee feature follows: call assertEnterprise...) first, then do the privileged work. The body here is a
|
|
4
4
|
* stub — the real audit sink is the hosted control plane. It exists now to prove the gate.
|
|
5
5
|
*/
|
|
6
|
-
import { assertEnterprise } from '../license/license.js';
|
|
6
|
+
import { assertEnterprise, EnterpriseFeature } from '../license/license.js';
|
|
7
7
|
/** Record an audit event — gated. Throws EnterpriseLicenseError in production without a valid license. */
|
|
8
8
|
export function recordAuditEvent(event, ctx) {
|
|
9
|
-
assertEnterprise(
|
|
9
|
+
assertEnterprise(EnterpriseFeature.AUDIT_LOG, ctx);
|
|
10
10
|
return event; // stub: a real implementation persists to the enterprise audit store
|
|
11
11
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
-
import type {
|
|
2
|
+
import type { OwnContradiction } from './contradictions.js';
|
|
3
3
|
/** Contradictions for 2xx responses whose bodies report failure. One per offending call. */
|
|
4
|
-
export declare function findBodyFailures(events: readonly ReticleEvent[]):
|
|
4
|
+
export declare function findBodyFailures(events: readonly ReticleEvent[]): OwnContradiction[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReticleEvent } from '@reticlehq/core';
|
|
2
|
+
import type { Contradiction, ContradictionOptions } from './contradictions.js';
|
|
3
|
+
/**
|
|
4
|
+
* Rules a CONSUMER adds to the contradiction pass, without editing the ones this package ships.
|
|
5
|
+
*
|
|
6
|
+
* Every rule in `contradictions.ts` is a pure fold over a recording — events in, findings out, no
|
|
7
|
+
* session, no clock, no IO. A service embedding this engine wants rules of exactly that shape for
|
|
8
|
+
* itself, and the only thing standing between it and one was that `findContradictions` folds over a
|
|
9
|
+
* list written into its own body. Appending to that list means editing this package, which means a
|
|
10
|
+
* fork, which means the consumer's rules and this package's rules become one tree to merge forever.
|
|
11
|
+
*
|
|
12
|
+
* A registry rather than a parameter on `findContradictions`, deliberately. There are six call sites
|
|
13
|
+
* and a consumer reaches none of them: the tools call the pass internally, and a tool handler is
|
|
14
|
+
* handed a deps bag, not options. Threading a parameter would mean touching every path a verdict can
|
|
15
|
+
* take — and would still miss the one inside `crawl`. Registration happens once, at boot, by whoever
|
|
16
|
+
* composed the process.
|
|
17
|
+
*
|
|
18
|
+
* This is not a public plugin API and should not be documented as one. It has a single intended
|
|
19
|
+
* consumer, which is a service we own, and its shape is free to change in lockstep with it.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* One consumer rule.
|
|
23
|
+
*
|
|
24
|
+
* Receives the app's OWN events — dev-tooling traffic is already removed, exactly as it is for the
|
|
25
|
+
* shipped rules. A second answer to "which events count" would be a second product.
|
|
26
|
+
*/
|
|
27
|
+
export type ContradictionFold = (events: readonly ReticleEvent[], options: ContradictionOptions) => readonly Contradiction[];
|
|
28
|
+
/**
|
|
29
|
+
* Add a rule to every verdict this engine produces. Returns the undo.
|
|
30
|
+
*
|
|
31
|
+
* The undo is not a convenience: without it a registry is process-global state that tests cannot
|
|
32
|
+
* clean up, so each one starts depending on the order it ran in.
|
|
33
|
+
*/
|
|
34
|
+
export declare function registerContradictionFold(fold: ContradictionFold): () => void;
|
|
35
|
+
/** What is registered right now. Exported so a guard can assert the registry is empty by default. */
|
|
36
|
+
export declare function registeredContradictionFolds(): readonly ContradictionFold[];
|
|
37
|
+
/**
|
|
38
|
+
* Run every registered rule, containing anything one of them throws.
|
|
39
|
+
*
|
|
40
|
+
* A registered fold is somebody else's code running inside every verdict path this package has. If a
|
|
41
|
+
* throw could propagate, one defect in a consumer's rule would take down `assert`, `act_and_wait`,
|
|
42
|
+
* `observe` and `crawl` at once — turning a missing finding into a dead engine. Contained and logged:
|
|
43
|
+
* the rules that did run still report, and the failure is visible rather than swallowed.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runRegisteredFolds(events: readonly ReticleEvent[], options: ContradictionOptions): Contradiction[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { log } from '../log.js';
|
|
2
|
+
const FOLDS = [];
|
|
3
|
+
/**
|
|
4
|
+
* Add a rule to every verdict this engine produces. Returns the undo.
|
|
5
|
+
*
|
|
6
|
+
* The undo is not a convenience: without it a registry is process-global state that tests cannot
|
|
7
|
+
* clean up, so each one starts depending on the order it ran in.
|
|
8
|
+
*/
|
|
9
|
+
export function registerContradictionFold(fold) {
|
|
10
|
+
FOLDS.push(fold);
|
|
11
|
+
return () => {
|
|
12
|
+
const at = FOLDS.indexOf(fold);
|
|
13
|
+
if (at !== -1)
|
|
14
|
+
FOLDS.splice(at, 1);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** What is registered right now. Exported so a guard can assert the registry is empty by default. */
|
|
18
|
+
export function registeredContradictionFolds() {
|
|
19
|
+
return [...FOLDS];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Run every registered rule, containing anything one of them throws.
|
|
23
|
+
*
|
|
24
|
+
* A registered fold is somebody else's code running inside every verdict path this package has. If a
|
|
25
|
+
* throw could propagate, one defect in a consumer's rule would take down `assert`, `act_and_wait`,
|
|
26
|
+
* `observe` and `crawl` at once — turning a missing finding into a dead engine. Contained and logged:
|
|
27
|
+
* the rules that did run still report, and the failure is visible rather than swallowed.
|
|
28
|
+
*/
|
|
29
|
+
export function runRegisteredFolds(events, options) {
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const fold of FOLDS) {
|
|
32
|
+
try {
|
|
33
|
+
found.push(...fold(events, options));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
log('contradiction_fold_failed', {
|
|
37
|
+
error: error instanceof Error ? error.message : String(error),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
@@ -19,7 +19,16 @@ import { type DeclaredNetFailure } from './declared.js';
|
|
|
19
19
|
* Pure: a window of events in, findings out. No session, no IO, no clock.
|
|
20
20
|
*/
|
|
21
21
|
export interface Contradiction {
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Typed `string`, not `ContradictionKind`, because the vocabulary is open at the EDGE.
|
|
24
|
+
*
|
|
25
|
+
* `ContradictionKind` enumerates what THIS package's rules emit, and every one of them is still
|
|
26
|
+
* checked against it — see `OwnContradiction`, which is what the folds below build. A rule
|
|
27
|
+
* registered by a consumer emits kinds this package has never heard of and must not have to add
|
|
28
|
+
* them here to be reportable: a shared enum is exactly how a consumer's private vocabulary ends up
|
|
29
|
+
* shipped in the free product by accident.
|
|
30
|
+
*/
|
|
31
|
+
kind: string;
|
|
23
32
|
/** What one channel asserted — the optimistic half. */
|
|
24
33
|
claim: string;
|
|
25
34
|
/** What the other channel asserted — the half that contradicts it. */
|
|
@@ -27,6 +36,15 @@ export interface Contradiction {
|
|
|
27
36
|
/** Concrete evidence, so the agent can go straight to the call or the control. */
|
|
28
37
|
detail: string;
|
|
29
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* A contradiction emitted by one of THIS package's rules — the kind is closed.
|
|
41
|
+
*
|
|
42
|
+
* Widening `Contradiction.kind` to `string` for the consumer seam would otherwise have made every
|
|
43
|
+
* emit site below accept a typo'd literal. This keeps them checked without closing the edge.
|
|
44
|
+
*/
|
|
45
|
+
export type OwnContradiction = Contradiction & {
|
|
46
|
+
kind: ContradictionKind;
|
|
47
|
+
};
|
|
30
48
|
export interface ContradictionOptions {
|
|
31
49
|
/** The action that opened this window, when one did. Enables the no-effect check. */
|
|
32
50
|
action?: string | undefined;
|
|
@@ -5,6 +5,7 @@ import { findEchoMismatches } from './echo-mismatch.js';
|
|
|
5
5
|
import { findUnitMismatches } from './unit-mismatch.js';
|
|
6
6
|
import { asNumber, asString } from '../tools/tools-helpers.js';
|
|
7
7
|
import { matchesDeclaredFailure } from './declared.js';
|
|
8
|
+
import { runRegisteredFolds } from './contradiction-folds.js';
|
|
8
9
|
function netCall(e) {
|
|
9
10
|
const status = asNumber(e.data['status']);
|
|
10
11
|
return {
|
|
@@ -255,7 +256,19 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
255
256
|
// claimed success" about an app that plainly reported a failure is true in outline and wrong in
|
|
256
257
|
// its reasoning, which is how a checker stops being believed.
|
|
257
258
|
const successSignals = signals.filter((name) => !ACKNOWLEDGED.test(name));
|
|
258
|
-
|
|
259
|
+
// An app that RETRACTED has not claimed success, whenever it fired the optimistic signal.
|
|
260
|
+
//
|
|
261
|
+
// The weaker UI rule below already consulted this and the sharper signal rule did not, so an app
|
|
262
|
+
// that announced "ack:requested", met a 500, and then correctly emitted "ack:failed" and rolled the
|
|
263
|
+
// row back was reported as explicitly asserting success — the strongest accusation this file makes,
|
|
264
|
+
// against code doing exactly the right thing. On a fixture suite the scenario's FIXED twin produced
|
|
265
|
+
// the same finding as the build that swallowed the failure, which makes the finding worthless on
|
|
266
|
+
// the one measurement that scores precision.
|
|
267
|
+
//
|
|
268
|
+
// Ordering cannot decide this: an optimistic UI legitimately fires its success signal BEFORE the
|
|
269
|
+
// response, so "the claim must follow the failure" would miss the real defect. What separates them
|
|
270
|
+
// is not when the app spoke, it is whether it took it back.
|
|
271
|
+
if (failed.length > 0 && successSignals.length > 0 && !failureAcknowledged(events)) {
|
|
259
272
|
found.push({
|
|
260
273
|
kind: ContradictionKind.SIGNAL_CONTRADICTED,
|
|
261
274
|
claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
|
|
@@ -341,5 +354,7 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
341
354
|
});
|
|
342
355
|
}
|
|
343
356
|
}
|
|
344
|
-
|
|
357
|
+
// Consumer rules run LAST and over the same app-only window, so a service embedding this engine
|
|
358
|
+
// adds to the verdict rather than forking the file that produces it.
|
|
359
|
+
return [...found, ...runRegisteredFolds(events, options)];
|
|
345
360
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
-
import type {
|
|
2
|
+
import type { OwnContradiction } from './contradictions.js';
|
|
3
3
|
/**
|
|
4
4
|
* Contradictions for writes whose response echoes a different value than the request asked for.
|
|
5
5
|
*
|
|
@@ -7,4 +7,4 @@ import type { Contradiction } from './contradictions.js';
|
|
|
7
7
|
* action that opened this window was dispatched. A finding about a request that had already settled
|
|
8
8
|
* before the agent acted otherwise reads as a verdict on the change it just made.
|
|
9
9
|
*/
|
|
10
|
-
export declare function findEchoMismatches(events: readonly ReticleEvent[], actionSince?: number):
|
|
10
|
+
export declare function findEchoMismatches(events: readonly ReticleEvent[], actionSince?: number): OwnContradiction[];
|
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
export declare function findStaleResponses(events: readonly ReticleEvent[]): Contradiction[];
|
|
2
|
+
import type { OwnContradiction } from './contradictions.js';
|
|
3
|
+
export declare function findStaleResponses(events: readonly ReticleEvent[]): OwnContradiction[];
|
|
@@ -91,6 +91,49 @@ function flights(events) {
|
|
|
91
91
|
*/
|
|
92
92
|
const MAX_FLIGHTS_PER_PATH = 60;
|
|
93
93
|
/** The one contradiction, or none. Reported once per window — a race is a fact about the window. */
|
|
94
|
+
/**
|
|
95
|
+
* How long after a superseded response lands we look for the app reacting to it.
|
|
96
|
+
*
|
|
97
|
+
* A handler runs on the microtask that resolves the fetch; the render follows within a frame or two.
|
|
98
|
+
* Beyond this the connection between the response and any change is speculation.
|
|
99
|
+
*/
|
|
100
|
+
const APPLY_WINDOW_MS = 500;
|
|
101
|
+
/**
|
|
102
|
+
* How far BEFORE the completion stamp a reaction still counts as reacting to it.
|
|
103
|
+
*
|
|
104
|
+
* Not a fudge factor — a correction for which of the two is stamped first. `NET_REQUEST` is emitted
|
|
105
|
+
* when the observer's wrapper sees the fetch settle, and the app's own handler runs on the same
|
|
106
|
+
* microtask; in practice the app's state write, signal and DOM text land at or a millisecond BEFORE
|
|
107
|
+
* the completion event that caused them.
|
|
108
|
+
*
|
|
109
|
+
* So a strictly-forward window could not see the application at all, and the rule could never fire on
|
|
110
|
+
* real traffic however plainly the app misbehaved. Sixteen passing unit tests did not catch that,
|
|
111
|
+
* because synthetic events are written with the reaction placed after the cause.
|
|
112
|
+
*/
|
|
113
|
+
const APPLY_SLACK_MS = 100;
|
|
114
|
+
/**
|
|
115
|
+
* Did the app REACT to this response — did anything user-visible move after it landed?
|
|
116
|
+
*
|
|
117
|
+
* The question the rule could not previously ask, and the difference between a defect and a
|
|
118
|
+
* correctly-handled race. An app that guards against superseded responses still RACES: two requests
|
|
119
|
+
* overlap and settle out of order exactly as before. What it does not do is act on the loser.
|
|
120
|
+
*
|
|
121
|
+
* Without this check the rule fires identically on both, which is worse than not having it: a
|
|
122
|
+
* detector that accuses correct code teaches people to ignore it, and it takes the true positives
|
|
123
|
+
* down with it.
|
|
124
|
+
*/
|
|
125
|
+
function appliedAfter(events, settledAt) {
|
|
126
|
+
return events.some((event) => {
|
|
127
|
+
if (event.t < settledAt - APPLY_SLACK_MS || event.t > settledAt + APPLY_WINDOW_MS)
|
|
128
|
+
return false;
|
|
129
|
+
return (event.type === EventType.STATE_CHANGE ||
|
|
130
|
+
event.type === EventType.SIGNAL ||
|
|
131
|
+
event.type === EventType.DOM_TEXT ||
|
|
132
|
+
event.type === EventType.DOM_ADDED ||
|
|
133
|
+
event.type === EventType.DOM_REMOVED ||
|
|
134
|
+
event.type === EventType.DOM_ATTR);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
94
137
|
export function findStaleResponses(events) {
|
|
95
138
|
// Grouped by path first: two reads of DIFFERENT endpoints can never race, and comparing them was
|
|
96
139
|
// the bulk of the work on any real page, which talks to many endpoints at once.
|
|
@@ -105,13 +148,13 @@ export function findStaleResponses(events) {
|
|
|
105
148
|
}
|
|
106
149
|
for (const group of byPath.values()) {
|
|
107
150
|
const reads = group.slice(-MAX_FLIGHTS_PER_PATH);
|
|
108
|
-
const found = raceIn(reads);
|
|
151
|
+
const found = raceIn(reads, events);
|
|
109
152
|
if (found !== undefined)
|
|
110
153
|
return [found];
|
|
111
154
|
}
|
|
112
155
|
return [];
|
|
113
156
|
}
|
|
114
|
-
function raceIn(reads) {
|
|
157
|
+
function raceIn(reads, events) {
|
|
115
158
|
for (const first of reads) {
|
|
116
159
|
for (const second of reads) {
|
|
117
160
|
if (first.id === second.id)
|
|
@@ -127,6 +170,10 @@ function raceIn(reads) {
|
|
|
127
170
|
// Parallel pagination is not a race — see differsOnlyByEnumeration.
|
|
128
171
|
if (differsOnlyByEnumeration(queryOf(first.url), queryOf(second.url)))
|
|
129
172
|
continue;
|
|
173
|
+
// The race happened. Whether it MATTERS is whether the app acted on the loser — an app that
|
|
174
|
+
// drops superseded responses races just as visibly and is not broken.
|
|
175
|
+
if (!appliedAfter(events, first.settledAt))
|
|
176
|
+
continue;
|
|
130
177
|
return {
|
|
131
178
|
kind: ContradictionKind.STALE_RESPONSE_APPLIED,
|
|
132
179
|
claim: `both reads of ${pathOf(first.url)} returned successfully and the page settled`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
-
import type {
|
|
2
|
+
import type { OwnContradiction } from './contradictions.js';
|
|
3
3
|
/**
|
|
4
4
|
* @param prior events from BEFORE the window, used only to learn what the API has stated.
|
|
5
5
|
*
|
|
@@ -13,4 +13,4 @@ import type { Contradiction } from './contradictions.js';
|
|
|
13
13
|
* every other rule blame this action for events it did not cause, and would re-report every earlier
|
|
14
14
|
* mistake on every assert.
|
|
15
15
|
*/
|
|
16
|
-
export declare function findUnitMismatches(events: readonly ReticleEvent[], prior?: readonly ReticleEvent[]):
|
|
16
|
+
export declare function findUnitMismatches(events: readonly ReticleEvent[], prior?: readonly ReticleEvent[]): OwnContradiction[];
|
package/dist/flows/flow-tools.js
CHANGED
|
@@ -292,7 +292,7 @@ export const FLOW_TOOLS = [
|
|
|
292
292
|
unverifiable: z
|
|
293
293
|
.object({ reason: z.string() })
|
|
294
294
|
.optional()
|
|
295
|
-
.describe('Set on an ok replay that cannot fail: the flow asserts no observable consequence, so it passes even when the feature is broken. Same reason
|
|
295
|
+
.describe('Set on an ok replay that cannot fail: the flow asserts no observable consequence, so it passes even when the feature is broken. Same reason reticle_verify{action:"flows"} reports.'),
|
|
296
296
|
decision: z
|
|
297
297
|
.object({
|
|
298
298
|
verdict: z.string(),
|
|
@@ -97,7 +97,7 @@ export const VERIFY_CHANGE_TOOLS = [
|
|
|
97
97
|
const cursor = cursorBeforeReplay(deps, args);
|
|
98
98
|
const verify = FLOW_TOOLS.find((tool) => tool.name === ReticleTool.FLOW_VERIFY);
|
|
99
99
|
if (verify === undefined)
|
|
100
|
-
throw new Error('
|
|
100
|
+
throw new Error('the whole-suite replay tool is not registered');
|
|
101
101
|
const suite = asRecord(await verify.handler(deps, {
|
|
102
102
|
names: affected,
|
|
103
103
|
...(asNumber(args['parallel']) === undefined ? {} : { parallel: args['parallel'] }),
|