@reticlehq/server 2.12.0 → 2.13.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 +67 -109
- package/dist/bridge/auth-failure-reason.d.ts +6 -2
- package/dist/bridge/auth-failure-reason.js +15 -8
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +247 -164
- package/dist/bridge/pairing-token.d.ts +5 -0
- package/dist/bridge/pairing-token.js +28 -0
- package/dist/bridge/protocol-skew.d.ts +37 -0
- package/dist/bridge/protocol-skew.js +45 -0
- package/dist/capsule/capsule-store.d.ts +6 -6
- package/dist/capsule/causal-summary.js +7 -2
- package/dist/cli/cli-doctor.js +31 -1
- package/dist/cli/cli-launch.d.ts +11 -1
- package/dist/cli/cli-launch.js +59 -10
- package/dist/cli/cli-parse.d.ts +15 -1
- package/dist/cli/cli-parse.js +212 -8
- package/dist/cli/cli-verify.d.ts +1 -0
- package/dist/cli/cli-verify.js +3 -2
- package/dist/cli/cloud-cli.js +428 -259
- package/dist/cli/cloud-keystore.d.ts +37 -0
- package/dist/cli/cloud-keystore.js +97 -0
- package/dist/cli/cloud-kit.d.ts +37 -0
- package/dist/cli/cloud-kit.js +121 -0
- package/dist/cli/cloud-login.d.ts +17 -0
- package/dist/cli/cloud-login.js +183 -0
- package/dist/cli/cloud-session.d.ts +48 -0
- package/dist/cli/cloud-session.js +53 -0
- package/dist/cli/config-discovery.js +1 -0
- package/dist/cli/daemon-start-options.d.ts +32 -0
- package/dist/cli/daemon-start-options.js +38 -0
- package/dist/cli/device-flow.d.ts +76 -0
- package/dist/cli/device-flow.js +56 -0
- package/dist/cli/doctor-rows.d.ts +9 -0
- package/dist/cli/doctor-rows.js +9 -0
- package/dist/cli/drive-attach.d.ts +13 -0
- package/dist/cli/drive-attach.js +18 -0
- package/dist/cli/drive-command.js +7 -3
- package/dist/cli/mcp-command.js +51 -20
- package/dist/cli/project-name.d.ts +30 -0
- package/dist/cli/project-name.js +44 -0
- package/dist/cli/status-next-action.d.ts +14 -0
- package/dist/cli/status-next-action.js +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +134 -80
- package/dist/cloud/cloud-config.d.ts +22 -1
- package/dist/cloud/cloud-config.js +64 -5
- package/dist/cloud/cloud-sync.d.ts +5 -5
- package/dist/cloud/cloud-sync.js +33 -2
- package/dist/cloud/sync-cycle.d.ts +110 -0
- package/dist/cloud/sync-cycle.js +270 -0
- package/dist/cloud/sync-daemon.d.ts +69 -0
- package/dist/cloud/sync-daemon.js +260 -0
- package/dist/cloud/sync-disk.d.ts +9 -0
- package/dist/cloud/sync-disk.js +191 -0
- package/dist/cloud/sync-hash.d.ts +2 -0
- package/dist/cloud/sync-hash.js +35 -0
- package/dist/crawl/crawl-tools.js +5 -3
- package/dist/crawl/crawl.d.ts +3 -0
- package/dist/crawl/crawl.js +5 -0
- package/dist/daemon/daemon-resolve.d.ts +43 -0
- package/dist/daemon/daemon-resolve.js +69 -0
- package/dist/daemon/dev-servers.d.ts +15 -0
- package/dist/daemon/dev-servers.js +68 -0
- package/dist/daemon/wake-decision.d.ts +32 -0
- package/dist/daemon/wake-decision.js +38 -0
- package/dist/domain/domain-tools.js +2 -2
- package/dist/events/contradictions.d.ts +18 -0
- package/dist/events/contradictions.js +134 -21
- package/dist/events/declared.d.ts +7 -4
- package/dist/events/declared.js +15 -6
- package/dist/events/echo-mismatch.js +64 -14
- package/dist/events/event-filters.d.ts +8 -0
- package/dist/events/event-filters.js +19 -2
- package/dist/events/predicate-eval.js +53 -5
- package/dist/events/predicate-precheck.d.ts +8 -0
- package/dist/events/predicate-precheck.js +41 -0
- package/dist/events/predicate-route.d.ts +14 -0
- package/dist/events/predicate-route.js +36 -8
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +37 -5
- package/dist/events/split-text-miss.d.ts +30 -0
- package/dist/events/split-text-miss.js +44 -0
- package/dist/flows/flake.d.ts +6 -6
- package/dist/flows/flow-memory-consult.d.ts +55 -0
- package/dist/flows/flow-memory-consult.js +38 -0
- package/dist/flows/flow-replay-run.d.ts +18 -7
- package/dist/flows/flow-replay-run.js +239 -22
- package/dist/flows/flow-replay.js +11 -2
- package/dist/flows/flow-step-runners.d.ts +8 -0
- package/dist/flows/flow-step-runners.js +8 -2
- package/dist/flows/flow-store-for-session.d.ts +31 -0
- package/dist/flows/flow-store-for-session.js +33 -0
- package/dist/flows/flow-tools.js +58 -32
- package/dist/flows/flows.d.ts +8 -0
- package/dist/flows/flows.js +468 -2
- package/dist/flows/heal-run.js +5 -2
- package/dist/flows/predicate-to-expect.d.ts +17 -12
- package/dist/flows/predicate-to-expect.js +30 -12
- package/dist/flows/recordings.d.ts +13 -1
- package/dist/flows/recordings.js +17 -2
- package/dist/flows/replay.d.ts +22 -2
- package/dist/flows/replay.js +28 -2
- package/dist/flows/verify-change-tools.js +5 -2
- package/dist/honesty/blind-spots.d.ts +55 -2
- package/dist/honesty/blind-spots.js +63 -3
- package/dist/honesty/honesty.d.ts +24 -0
- package/dist/honesty/honesty.js +25 -1
- package/dist/honesty/instrumentation-gaps.d.ts +9 -0
- package/dist/honesty/instrumentation-gaps.js +29 -2
- package/dist/honesty/verified.d.ts +2 -1
- package/dist/honesty/verified.js +16 -1
- package/dist/http-server.js +12 -0
- package/dist/impact/impact-recorder.d.ts +31 -8
- package/dist/impact/impact-recorder.js +104 -21
- package/dist/impact/impact-store.d.ts +15 -1
- package/dist/impact/impact-store.js +43 -2
- package/dist/index.js +113 -7
- package/dist/init/agent-rules.d.ts +13 -6
- package/dist/init/agent-rules.js +23 -12
- package/dist/init/astro-patch.d.ts +16 -0
- package/dist/init/astro-patch.js +28 -0
- package/dist/init/capabilities.d.ts +4 -1
- package/dist/init/capabilities.js +22 -2
- package/dist/init/confirm.d.ts +31 -7
- package/dist/init/confirm.js +48 -8
- package/dist/init/cra.d.ts +20 -4
- package/dist/init/cra.js +57 -17
- package/dist/init/csp-check.js +17 -2
- package/dist/init/csp-doctor.d.ts +8 -1
- package/dist/init/csp-doctor.js +8 -3
- package/dist/init/dev-script.d.ts +3 -1
- package/dist/init/dev-script.js +8 -5
- package/dist/init/format-generated.d.ts +18 -0
- package/dist/init/format-generated.js +39 -0
- package/dist/init/install-hint.d.ts +9 -0
- package/dist/init/install-hint.js +29 -0
- package/dist/init/mcp-clients.d.ts +7 -0
- package/dist/init/mcp-clients.js +7 -0
- package/dist/init/mcp.d.ts +1 -1
- package/dist/init/mcp.js +1 -1
- package/dist/init/node-io.js +11 -1
- package/dist/init/plan-framework.js +81 -45
- package/dist/init/plan.d.ts +20 -7
- package/dist/init/plan.js +20 -45
- package/dist/init/preflight.d.ts +28 -0
- package/dist/init/preflight.js +38 -0
- package/dist/init/run.d.ts +32 -9
- package/dist/init/run.js +141 -119
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/snippets.js +41 -25
- package/dist/init/vite-config.d.ts +1 -1
- package/dist/init/vite-config.js +39 -25
- package/dist/init/workspace-apps.d.ts +17 -10
- package/dist/init/workspace-apps.js +110 -0
- package/dist/input/network-mock-tools.js +3 -6
- package/dist/input/real-input.js +25 -5
- package/dist/input/scroll-tools.js +4 -6
- package/dist/input/viewport-tools.js +5 -4
- package/dist/intent/inline-intent.d.ts +10 -19
- package/dist/intent/inline-intent.js +91 -1
- package/dist/intent/intent-shard-store.d.ts +72 -0
- package/dist/intent/intent-shard-store.js +203 -0
- package/dist/intent/intent-shard.d.ts +411 -0
- package/dist/intent/intent-shard.js +176 -0
- package/dist/intent/intent-store.d.ts +12 -0
- package/dist/intent/intent-store.js +21 -0
- package/dist/intent/intent-subject.d.ts +45 -0
- package/dist/intent/intent-subject.js +177 -0
- package/dist/intent/intent-tools.js +65 -2
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +15 -0
- package/dist/license/license-env.d.ts +29 -0
- package/dist/license/license-env.js +148 -0
- package/dist/loopback-agent.d.ts +31 -0
- package/dist/loopback-agent.js +57 -0
- package/dist/mcp/attach-memory.d.ts +18 -2
- package/dist/mcp/attach-memory.js +50 -7
- package/dist/mcp/client-identity.d.ts +31 -0
- package/dist/mcp/client-identity.js +51 -0
- package/dist/mcp/mcp-post-transport.d.ts +26 -0
- package/dist/mcp/mcp-post-transport.js +109 -0
- package/dist/mcp/mcp-proxy.d.ts +2 -1
- package/dist/mcp/mcp-proxy.js +52 -62
- package/dist/mcp/mcp.js +7 -1
- package/dist/mcp/proxy-backoff.d.ts +11 -0
- package/dist/mcp/proxy-backoff.js +13 -0
- package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
- package/dist/mcp/proxy-daemon-probe.js +7 -1
- package/dist/mcp/proxy-handshake.d.ts +23 -0
- package/dist/mcp/proxy-handshake.js +31 -0
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +12 -0
- package/dist/memory/memory-tools.d.ts +2 -0
- package/dist/memory/memory-tools.js +82 -0
- package/dist/memory/project-memory.d.ts +34 -0
- package/dist/memory/project-memory.js +96 -0
- package/dist/oracles/propose-consequences.js +12 -3
- package/dist/pool/browser-pool.d.ts +63 -4
- package/dist/pool/browser-pool.js +113 -4
- package/dist/pool/playwright-launcher.js +9 -0
- package/dist/project/project-tools.js +3 -3
- package/dist/project/reticle-dir.d.ts +15 -2
- package/dist/project/reticle-dir.js +21 -4
- package/dist/runs/run-store.d.ts +10 -0
- package/dist/runs/run-store.js +10 -0
- package/dist/runs/verification-sync.d.ts +0 -11
- package/dist/runs/verification-sync.js +22 -1
- package/dist/runs/verify-server.d.ts +8 -1
- package/dist/runs/verify-server.js +14 -2
- package/dist/session/command-timeout.d.ts +5 -2
- package/dist/session/command-timeout.js +5 -4
- package/dist/session/lease-visibility.d.ts +67 -0
- package/dist/session/lease-visibility.js +76 -0
- package/dist/session/no-session-diagnosis.d.ts +23 -0
- package/dist/session/no-session-diagnosis.js +58 -17
- package/dist/session/no-session-next-action.d.ts +7 -0
- package/dist/session/no-session-next-action.js +42 -3
- package/dist/session/no-session-watch.d.ts +10 -6
- package/dist/session/no-session-watch.js +51 -19
- package/dist/session/pending-commands.d.ts +11 -1
- package/dist/session/pending-commands.js +12 -2
- package/dist/session/resolve-within.d.ts +34 -0
- package/dist/session/resolve-within.js +53 -0
- package/dist/session/session-info.d.ts +18 -0
- package/dist/session/session-info.js +11 -0
- package/dist/session/session-manager.d.ts +14 -0
- package/dist/session/session-manager.js +61 -3
- package/dist/session/session-mirror.d.ts +16 -0
- package/dist/session/session-mirror.js +22 -0
- package/dist/session/session-recommendation.d.ts +19 -5
- package/dist/session/session-recommendation.js +25 -9
- package/dist/session/session-successor.js +36 -1
- package/dist/session/session-tools.js +2 -2
- package/dist/session/session.d.ts +22 -2
- package/dist/session/session.js +87 -7
- package/dist/session/wall-clock.d.ts +12 -0
- package/dist/session/wall-clock.js +14 -0
- package/dist/setup/agent-configs.d.ts +96 -0
- package/dist/setup/agent-configs.js +333 -0
- package/dist/setup/agent-io.d.ts +9 -0
- package/dist/setup/agent-io.js +16 -0
- package/dist/setup/agent-writer.d.ts +51 -0
- package/dist/setup/agent-writer.js +115 -0
- package/dist/setup/approval-migration.d.ts +47 -0
- package/dist/setup/approval-migration.js +68 -0
- package/dist/setup/auto-approve.d.ts +85 -0
- package/dist/setup/auto-approve.js +150 -0
- package/dist/setup/bridge-port.d.ts +15 -0
- package/dist/setup/bridge-port.js +24 -0
- package/dist/setup/desktop-shape.d.ts +57 -0
- package/dist/setup/desktop-shape.js +63 -0
- package/dist/setup/dev-server-wait.d.ts +60 -0
- package/dist/setup/dev-server-wait.js +65 -0
- package/dist/setup/drive-agent.d.ts +71 -0
- package/dist/setup/drive-agent.js +171 -0
- package/dist/setup/drive-plan.d.ts +68 -0
- package/dist/setup/drive-plan.js +130 -0
- package/dist/setup/ensure-daemon.d.ts +29 -0
- package/dist/setup/ensure-daemon.js +58 -0
- package/dist/setup/init-runtime.d.ts +47 -0
- package/dist/setup/init-runtime.js +160 -0
- package/dist/setup/interrupt.d.ts +25 -0
- package/dist/setup/interrupt.js +40 -0
- package/dist/setup/license-key.d.ts +39 -0
- package/dist/setup/license-key.js +72 -0
- package/dist/setup/listeners.d.ts +37 -0
- package/dist/setup/listeners.js +85 -0
- package/dist/setup/node-effects.d.ts +60 -0
- package/dist/setup/node-effects.js +210 -0
- package/dist/setup/page-probe.d.ts +39 -0
- package/dist/setup/page-probe.js +49 -0
- package/dist/setup/relaunch.d.ts +36 -0
- package/dist/setup/relaunch.js +57 -0
- package/dist/setup/remaining-steps.d.ts +19 -0
- package/dist/setup/remaining-steps.js +31 -0
- package/dist/setup/run-setup.d.ts +99 -0
- package/dist/setup/run-setup.js +187 -0
- package/dist/setup/session-pick.d.ts +47 -0
- package/dist/setup/session-pick.js +51 -0
- package/dist/setup/setup-command.d.ts +45 -0
- package/dist/setup/setup-command.js +199 -0
- package/dist/setup/setup-options.d.ts +74 -0
- package/dist/setup/setup-options.js +67 -0
- package/dist/setup/transcripts.d.ts +16 -0
- package/dist/setup/transcripts.js +43 -0
- package/dist/status-payload.d.ts +14 -1
- package/dist/status-payload.js +23 -1
- package/dist/telemetry/daemon-telemetry.d.ts +8 -1
- package/dist/telemetry/daemon-telemetry.js +52 -5
- package/dist/telemetry/error-class.js +1 -1
- package/dist/telemetry/feedback-context.d.ts +9 -1
- package/dist/telemetry/feedback-context.js +60 -29
- package/dist/telemetry/feedback-local.d.ts +15 -0
- package/dist/telemetry/feedback-local.js +56 -0
- package/dist/telemetry/feedback.d.ts +11 -0
- package/dist/telemetry/feedback.js +19 -6
- package/dist/telemetry/init-telemetry.d.ts +2 -0
- package/dist/telemetry/init-telemetry.js +2 -0
- package/dist/telemetry/install-source.d.ts +14 -0
- package/dist/telemetry/install-source.js +30 -0
- package/dist/telemetry/license-activation.d.ts +7 -1
- package/dist/telemetry/license-activation.js +9 -5
- package/dist/telemetry/mcp-connection.js +7 -0
- package/dist/telemetry/project-profile.d.ts +12 -1
- package/dist/telemetry/project-profile.js +11 -6
- package/dist/telemetry/proxy-telemetry.d.ts +3 -0
- package/dist/telemetry/proxy-telemetry.js +21 -0
- package/dist/telemetry/session-metrics.d.ts +12 -0
- package/dist/telemetry/session-metrics.js +35 -1
- package/dist/telemetry/telemetry.js +17 -2
- package/dist/telemetry/tool-refused.d.ts +3 -1
- package/dist/telemetry/tool-refused.js +34 -2
- package/dist/tools/act-capsule.d.ts +10 -0
- package/dist/tools/act-capsule.js +8 -2
- package/dist/tools/act-danger.d.ts +2 -1
- package/dist/tools/act-danger.js +11 -3
- package/dist/tools/act-observation.d.ts +41 -0
- package/dist/tools/act-observation.js +36 -0
- package/dist/tools/act-preflight.d.ts +20 -0
- package/dist/tools/act-preflight.js +98 -0
- package/dist/tools/act-sequence-retry.d.ts +45 -0
- package/dist/tools/act-sequence-retry.js +58 -0
- package/dist/tools/act-sequence-tool.d.ts +13 -0
- package/dist/tools/act-sequence-tool.js +135 -0
- package/dist/tools/act-target.d.ts +14 -0
- package/dist/tools/act-target.js +47 -0
- package/dist/tools/act-tools.d.ts +21 -1
- package/dist/tools/act-tools.js +100 -213
- package/dist/tools/assert-verdict.js +22 -16
- package/dist/tools/browser-tools.js +6 -2
- package/dist/tools/dynamic-tools.js +24 -5
- package/dist/tools/error-recovery.d.ts +1 -0
- package/dist/tools/error-recovery.js +8 -0
- package/dist/tools/invoke-tool.js +59 -9
- package/dist/tools/lease-tools.d.ts +30 -4
- package/dist/tools/lease-tools.js +169 -5
- package/dist/tools/merged-name-redirect.d.ts +13 -0
- package/dist/tools/merged-name-redirect.js +22 -0
- package/dist/tools/numeric-bounds.d.ts +100 -0
- package/dist/tools/numeric-bounds.js +125 -0
- package/dist/tools/observe-tools.js +47 -40
- package/dist/tools/read-tools.js +28 -7
- package/dist/tools/real-input-attempt.d.ts +10 -0
- package/dist/tools/real-input-attempt.js +59 -1
- package/dist/tools/reconcile-tools.js +4 -4
- package/dist/tools/tool-kit.d.ts +12 -0
- package/dist/tools/tool-kit.js +6 -1
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tools-helpers.d.ts +19 -0
- package/dist/tools/tools-helpers.js +46 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +56 -8
- package/dist/update/reticle-deps.d.ts +9 -0
- package/dist/update/reticle-deps.js +27 -0
- package/dist/update/sdk-sync.d.ts +1 -2
- package/dist/update/sdk-sync.js +1 -21
- package/dist/update/update-nudge.d.ts +18 -0
- package/dist/update/update-nudge.js +20 -0
- package/dist/version/peer-announce.js +4 -2
- package/dist/version/sdk-fix.d.ts +47 -0
- package/dist/version/sdk-fix.js +127 -0
- package/dist/version/version-nudge.d.ts +14 -0
- package/dist/version/version-nudge.js +21 -0
- package/dist/version/version-skew.d.ts +38 -3
- package/dist/version/version-skew.js +83 -7
- package/dist/visual/visual-store.d.ts +6 -6
- package/dist/visual/visual-store.js +18 -16
- package/dist/visual/visual-tools.js +57 -12
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/serve.mdx +4 -2
- package/docs/cli.mdx +7 -3
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +1 -1
- package/docs/getting-started.md +19 -3
- package/docs/index.mdx +9 -6
- package/docs/install-agentic.mdx +38 -12
- package/docs/install-manual.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/skill-file.mdx +3 -3
- package/docs/telemetry-contract.md +108 -4
- package/docs/tools-act-sequence.mdx +16 -0
- package/docs/usage.md +1 -1
- package/package.json +4 -3
- package/dist/init/cursor.d.ts +0 -24
- package/dist/init/cursor.js +0 -80
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ContradictionKind, EventType, MUTATING_METHODS, isDevToolingUrl, isSameDocument, isSameEditEpoch, } from '@reticlehq/core';
|
|
1
|
+
import { ContradictionKind, EventType, MUTATING_METHODS, isDevToolingUrl, isSameDocument, isThirdPartyUrl, isSameEditEpoch, urlForMatch, } from '@reticlehq/core';
|
|
2
2
|
import { describeSuperseded } from './observed-in-window.js';
|
|
3
3
|
import { findStaleResponses } from './stale-response.js';
|
|
4
4
|
import { findBodyFailures } from './body-failures.js';
|
|
@@ -12,6 +12,7 @@ function netCall(e) {
|
|
|
12
12
|
return {
|
|
13
13
|
method: (asString(e.data['method']) ?? '').toUpperCase(),
|
|
14
14
|
url: asString(e.data['url']) ?? '',
|
|
15
|
+
matchUrl: urlForMatch(e.data),
|
|
15
16
|
status,
|
|
16
17
|
// `ok` is authoritative when present (IPC sets it explicitly); status is the HTTP fallback.
|
|
17
18
|
// Neither present = no verdict was ever reported, which stays undefined all the way through.
|
|
@@ -139,6 +140,45 @@ const NET_TYPES = new Set([
|
|
|
139
140
|
EventType.NET_REQUEST,
|
|
140
141
|
EventType.NET_STREAM,
|
|
141
142
|
]);
|
|
143
|
+
/**
|
|
144
|
+
* Hash-router paths put the route in the fragment (`#/settings`, `#!/home`). An in-page skip link
|
|
145
|
+
* does not (`#main-content`, `#`, empty). The blank-destination rule must still see the former.
|
|
146
|
+
*/
|
|
147
|
+
function isInPageFragment(hash) {
|
|
148
|
+
if ('' === hash || '#' === hash)
|
|
149
|
+
return true;
|
|
150
|
+
const body = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
151
|
+
return !body.startsWith('/') && !body.startsWith('!');
|
|
152
|
+
}
|
|
153
|
+
function hrefAsUrl(value) {
|
|
154
|
+
if (value === undefined || '' === value)
|
|
155
|
+
return undefined;
|
|
156
|
+
try {
|
|
157
|
+
return new URL(value);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Same origin + pathname + search, different in-page fragment — a skip link, not a new view.
|
|
165
|
+
*
|
|
166
|
+
* Returns false when `from`/`to` are missing, so older events without hrefs keep the existing rule.
|
|
167
|
+
*/
|
|
168
|
+
function isSameDocumentHashAnchor(event) {
|
|
169
|
+
if (event.type !== EventType.ROUTE_CHANGE)
|
|
170
|
+
return false;
|
|
171
|
+
const from = hrefAsUrl(asString(event.data['from']));
|
|
172
|
+
const to = hrefAsUrl(asString(event.data['to']));
|
|
173
|
+
if (from === undefined || to === undefined)
|
|
174
|
+
return false;
|
|
175
|
+
if (from.origin !== to.origin || from.pathname !== to.pathname || from.search !== to.search) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (from.hash === to.hash)
|
|
179
|
+
return false;
|
|
180
|
+
return isInPageFragment(to.hash);
|
|
181
|
+
}
|
|
142
182
|
/**
|
|
143
183
|
* Split the window into the app's traffic and the dev toolchain's own (see `DevToolingChannel`).
|
|
144
184
|
*
|
|
@@ -149,13 +189,15 @@ const NET_TYPES = new Set([
|
|
|
149
189
|
* every action. The overlay's own 404s and duplicate fetches are the same story on other checks,
|
|
150
190
|
* which is why the split happens ONCE here rather than in the one check that reported it.
|
|
151
191
|
*/
|
|
152
|
-
function
|
|
192
|
+
function splitForeignTraffic(events, appOrigin) {
|
|
153
193
|
const ignored = [];
|
|
154
194
|
const app = events.filter((e) => {
|
|
155
195
|
if (!NET_TYPES.has(e.type))
|
|
156
196
|
return true;
|
|
157
197
|
const url = asString(e.data['url']);
|
|
158
|
-
|
|
198
|
+
// Somebody else's code, twice over: the toolchain's own channel, and any site that is not the
|
|
199
|
+
// app under test. Neither can answer the question every rule below asks.
|
|
200
|
+
if (!isDevToolingUrl(url) && !isThirdPartyUrl(url, appOrigin))
|
|
159
201
|
return true;
|
|
160
202
|
if (url !== undefined && !ignored.includes(url))
|
|
161
203
|
ignored.push(url);
|
|
@@ -199,19 +241,19 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
199
241
|
}
|
|
200
242
|
function findWindowContradictions(allEvents, options) {
|
|
201
243
|
const found = [];
|
|
202
|
-
const { app: allApp, ignored:
|
|
244
|
+
const { app: allApp, ignored: ignoredForeign } = splitForeignTraffic(allEvents, options.appOrigin);
|
|
203
245
|
// ── Evidence belonging to a document that has since been replaced ───────────────────────────
|
|
204
246
|
// Scoped ONCE, here, for the same reason the dev-tooling split is: every rule below asks "what
|
|
205
247
|
// else was in this window", and answering that with a dead page's traffic is a defect in all of
|
|
206
248
|
// them rather than in whichever one reported it. Applied after the dev-tooling split so the count
|
|
207
249
|
// reported below is the app's own evidence and not the toolchain's noise.
|
|
208
|
-
const
|
|
209
|
-
const superseded = allApp.length -
|
|
250
|
+
const scoped = allApp.filter((e) => isSameDocument(e.documentId, options.currentDocumentId));
|
|
251
|
+
const superseded = allApp.length - scoped.length;
|
|
210
252
|
// An empty window was always allowed to mean "nothing happened"; that reading is only unsafe once
|
|
211
253
|
// supersession is what emptied it. Reported ALONE and before every rule below, because a window
|
|
212
254
|
// with nothing left in it is exactly the shape `action-had-no-effect` fires on — so without this
|
|
213
255
|
// the fix would have swapped a wrong citation for a wrong accusation.
|
|
214
|
-
if (superseded > 0 && 0 ===
|
|
256
|
+
if (superseded > 0 && 0 === scoped.length) {
|
|
215
257
|
return [
|
|
216
258
|
{
|
|
217
259
|
kind: ContradictionKind.EVIDENCE_SUPERSEDED,
|
|
@@ -221,6 +263,18 @@ function findWindowContradictions(allEvents, options) {
|
|
|
221
263
|
},
|
|
222
264
|
];
|
|
223
265
|
}
|
|
266
|
+
// ── Evidence that predates the action ───────────────────────────────────────────────────────
|
|
267
|
+
// The attribution floor, and it is scoped ONCE here for the same reason the two filters above are:
|
|
268
|
+
// a rule that reads "what else was in this window" and gets handed traffic from before the caller
|
|
269
|
+
// acted is not refining its answer, it is answering about somebody else's action. Three rules
|
|
270
|
+
// (`duplicate-request`, `signal-without-consequence`, `consequence-elsewhere`) each checked this
|
|
271
|
+
// for themselves and the other six swept the whole window — which is how an assert with no `since`
|
|
272
|
+
// came to be judged against everything that had ever happened in the tab.
|
|
273
|
+
//
|
|
274
|
+
// Undefined means nothing attributed the window to an action at all; the floor then does nothing,
|
|
275
|
+
// and the consequence rules below decline to speak instead. See `advanced`.
|
|
276
|
+
const floor = options.actionSince;
|
|
277
|
+
const events = floor === undefined ? scoped : scoped.filter((e) => e.t >= floor);
|
|
224
278
|
const settled = events.filter((e) => e.type === EventType.NET_REQUEST).map(netCall);
|
|
225
279
|
// ── Overlapping reads that settled out of order ─────────────────────────────────────────────
|
|
226
280
|
// Independent of any action, so it runs on every window: the race is a property of the timeline.
|
|
@@ -253,7 +307,22 @@ function findWindowContradictions(allEvents, options) {
|
|
|
253
307
|
// The failures NOBODY declared — see ContradictionOptions.expectedFailures. Only the heuristic
|
|
254
308
|
// "the UI moved while a request failed" rule reads this; the sharp rules still read `failed`.
|
|
255
309
|
const unexpected = failed.filter((c) => !matchesDeclaredFailure(c, options.expectedFailures ?? []));
|
|
256
|
-
|
|
310
|
+
/**
|
|
311
|
+
* Did the UI move — and is anybody entitled to say so?
|
|
312
|
+
*
|
|
313
|
+
* `undefined` is the third answer and the point of the tri-state: "the UI moved forward while a
|
|
314
|
+
* request failed" is a claim about CAUSATION, and over a window nothing attributed to an action
|
|
315
|
+
* the two halves merely co-occurred. A passive `reticle_assert` performs nothing, so ambient
|
|
316
|
+
* traffic — a poll, a page-load bootstrap, a branding call — is not its consequence and must not
|
|
317
|
+
* decide its verdict.
|
|
318
|
+
*
|
|
319
|
+
* Typed rather than gated with a boolean so it cannot be skipped: every rule that reasons from UI
|
|
320
|
+
* movement has to answer `undefined` explicitly, including one added later. The rules that read
|
|
321
|
+
* the app's OWN claims (a success signal over a failed call) or its payloads (a 2xx whose body
|
|
322
|
+
* says it failed, a field echoed back wrong) are untouched — those are things the app said, not
|
|
323
|
+
* consequences anybody inferred, and they are true whoever caused them.
|
|
324
|
+
*/
|
|
325
|
+
const advanced = floor === undefined ? undefined : uiAdvanced(events);
|
|
257
326
|
const signals = events
|
|
258
327
|
.filter((e) => e.type === EventType.SIGNAL)
|
|
259
328
|
.map((e) => asString(e.data['name']) ?? 'signal');
|
|
@@ -261,7 +330,13 @@ function findWindowContradictions(allEvents, options) {
|
|
|
261
330
|
// A navigation that neither fetches nor renders arrived nowhere. Distinct from a dead control:
|
|
262
331
|
// the control worked, the DESTINATION is empty — which is why every "did the click do something"
|
|
263
332
|
// heuristic passes it, a route change being unambiguously something.
|
|
264
|
-
const
|
|
333
|
+
const routeEvents = events.filter((e) => e.type === EventType.ROUTE_CHANGE);
|
|
334
|
+
const routed = routeEvents.length > 0;
|
|
335
|
+
// A skip link (`href="#main-content"`) is a same-document hash change. The observable
|
|
336
|
+
// consequences are location.hash, focus, and scroll — not a DOM mutation. Treating it as a
|
|
337
|
+
// blank destination made "did my skip link work" unanswerable. Hash-router paths (`#/invoices`)
|
|
338
|
+
// still go through the rule: those ARE a new view.
|
|
339
|
+
const hashAnchorOnly = routed && routeEvents.every(isSameDocumentHashAnchor);
|
|
265
340
|
// `dom.text` counts as rendered, and it has to: React reconciles a destination IN PLACE far more
|
|
266
341
|
// often than it adds nodes. Measured on three ordinary sidebar navigations of the bench app — every
|
|
267
342
|
// one emitted { dom.attr:2, dom.text:2, render.commit, state.change } and ZERO dom.added/removed,
|
|
@@ -276,7 +351,7 @@ function findWindowContradictions(allEvents, options) {
|
|
|
276
351
|
e.type === EventType.DOM_REMOVED ||
|
|
277
352
|
e.type === EventType.DOM_TEXT);
|
|
278
353
|
const fetched = events.some((e) => e.type === EventType.NET_REQUEST || e.type === EventType.NET_PENDING);
|
|
279
|
-
if (routed && !rendered && !fetched && true !== options.renderProved) {
|
|
354
|
+
if (routed && !hashAnchorOnly && !rendered && !fetched && true !== options.renderProved) {
|
|
280
355
|
found.push({
|
|
281
356
|
kind: ContradictionKind.ROUTE_RENDERED_NOTHING,
|
|
282
357
|
claim: 'the app navigated to a new route',
|
|
@@ -324,26 +399,48 @@ function findWindowContradictions(allEvents, options) {
|
|
|
324
399
|
// Ordering cannot decide this: an optimistic UI legitimately fires its success signal BEFORE the
|
|
325
400
|
// response, so "the claim must follow the failure" would miss the real defect. What separates them
|
|
326
401
|
// is not when the app spoke, it is whether it took it back.
|
|
327
|
-
|
|
402
|
+
// WHAT failed decides this, not whether an action was attributed.
|
|
403
|
+
//
|
|
404
|
+
// The claim is a thing the app said, so the attribution floor does not apply to it — the flagship
|
|
405
|
+
// false green is an app asserting success on a PASSIVE assert while its own write failed, and
|
|
406
|
+
// requiring an action would lose exactly that. But the COUNTER was any failure in the window, and
|
|
407
|
+
// that is the same window statement scoped out of every other rule here. Measured: two of the
|
|
408
|
+
// three false positives surviving the first scoping pass were this rule.
|
|
409
|
+
//
|
|
410
|
+
// A success signal claims a CHANGE was made. A failed mutation is evidence against that claim; a
|
|
411
|
+
// failed read is not. Background polls, prefetches and telemetry GETs fail constantly in healthy
|
|
412
|
+
// apps and say nothing about whether a write landed. Structural, not a timing heuristic, and the
|
|
413
|
+
// distinction was already encoded next door in `isMutating`.
|
|
414
|
+
const failedWrites = failed.filter(isMutating);
|
|
415
|
+
// The same scoping, for the same reason, one branch down. "The UI moved forward" is also a claim
|
|
416
|
+
// that something CHANGED, and it was still reading ANY failure — so a first-party poll failing
|
|
417
|
+
// during an action contradicted a verdict the action had genuinely earned. Measured on the
|
|
418
|
+
// observation benchmark: one of two false positives across 47 cells, and the argument for it is
|
|
419
|
+
// the paragraph above, which had simply not been carried down here.
|
|
420
|
+
const unexpectedWrites = unexpected.filter(isMutating);
|
|
421
|
+
if (failedWrites.length > 0 && successSignals.length > 0 && !failureAcknowledged(events)) {
|
|
328
422
|
found.push({
|
|
329
423
|
kind: ContradictionKind.SIGNAL_CONTRADICTED,
|
|
330
424
|
claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
|
|
331
|
-
counter: `${String(
|
|
332
|
-
detail:
|
|
425
|
+
counter: `${String(failedWrites.length)} write(s) in the same window failed`,
|
|
426
|
+
detail: failedWrites.map(describe).join('; '),
|
|
333
427
|
});
|
|
334
428
|
}
|
|
335
|
-
else if (
|
|
429
|
+
else if (unexpectedWrites.length > 0 &&
|
|
430
|
+
true === advanced &&
|
|
431
|
+
!misattributed &&
|
|
432
|
+
!failureAcknowledged(events)) {
|
|
336
433
|
found.push({
|
|
337
434
|
kind: ContradictionKind.UI_ADVANCED_REQUEST_FAILED,
|
|
338
435
|
claim: 'the UI moved forward (DOM/store/route changed)',
|
|
339
|
-
counter: `${String(
|
|
340
|
-
detail:
|
|
436
|
+
counter: `${String(unexpectedWrites.length)} request(s) in the same window failed`,
|
|
437
|
+
detail: unexpectedWrites.map(describe).join('; '),
|
|
341
438
|
});
|
|
342
439
|
}
|
|
343
440
|
// ── A write succeeded and nothing on the client moved ───────────────────────────────────────
|
|
344
441
|
// Writes only: a GET that changes nothing is a prefetch; a POST that changes nothing is a lost
|
|
345
442
|
// write, a response parsed into the void, or a render that never happened.
|
|
346
|
-
if (
|
|
443
|
+
if (false === advanced) {
|
|
347
444
|
const ignoredWrites = settled.filter((c) => true === c.ok && isMutating(c));
|
|
348
445
|
if (ignoredWrites.length > 0) {
|
|
349
446
|
// ...unless THIS document handed the consequence to another browsing context. An OAuth sign-in
|
|
@@ -387,7 +484,7 @@ function findWindowContradictions(allEvents, options) {
|
|
|
387
484
|
// existing tests that assert exactly that, which is the false-positive class this scoping exists
|
|
388
485
|
// to prevent.
|
|
389
486
|
if (options.actionSince !== undefined &&
|
|
390
|
-
|
|
487
|
+
false === advanced &&
|
|
391
488
|
0 === settled.length &&
|
|
392
489
|
successSignals.length > 0) {
|
|
393
490
|
found.push({
|
|
@@ -413,10 +510,26 @@ function findWindowContradictions(allEvents, options) {
|
|
|
413
510
|
// compares nothing rather than guess.
|
|
414
511
|
const actionSince = options.actionSince;
|
|
415
512
|
if (actionSince !== undefined) {
|
|
513
|
+
/**
|
|
514
|
+
* When the window navigated, and therefore when a write stops belonging to the user's action.
|
|
515
|
+
*
|
|
516
|
+
* React StrictMode double-invokes a mount effect in development, so clicking a nav link lands
|
|
517
|
+
* two identical writes inside the action's own window. Nothing scoped them out and they read as
|
|
518
|
+
* a double submit — measured on the observation benchmark as one of two false positives.
|
|
519
|
+
*
|
|
520
|
+
* The route change is the structural tell, not a heuristic: the claim this rule makes is "one
|
|
521
|
+
* user action was performed", and writes that follow a navigation belong to the mount of the
|
|
522
|
+
* view navigated TO. A real double submit fires from the view it is already on, with nothing in
|
|
523
|
+
* between — and one that navigates AFTER submitting is still counted, because the order is what
|
|
524
|
+
* distinguishes them.
|
|
525
|
+
*/
|
|
526
|
+
const navigatedAt = events.find((e) => EventType.ROUTE_CHANGE === e.type && e.t >= actionSince)?.t;
|
|
416
527
|
const writeCounts = new Map();
|
|
417
528
|
for (const event of events) {
|
|
418
529
|
if (event.type !== EventType.NET_REQUEST || event.t < actionSince)
|
|
419
530
|
continue;
|
|
531
|
+
if (navigatedAt !== undefined && event.t >= navigatedAt)
|
|
532
|
+
continue;
|
|
420
533
|
const call = netCall(event);
|
|
421
534
|
if (!isMutating(call))
|
|
422
535
|
continue;
|
|
@@ -442,7 +555,7 @@ function findWindowContradictions(allEvents, options) {
|
|
|
442
555
|
// Gated on the UI having moved: an in-flight request while the app is still visibly waiting is
|
|
443
556
|
// just a slow request, not a contradiction. It becomes one when the app proceeded regardless —
|
|
444
557
|
// which is also what makes a later `{ kind: "settled" }` assertion a false green.
|
|
445
|
-
if (advanced) {
|
|
558
|
+
if (true === advanced) {
|
|
446
559
|
const settledIds = new Set(events
|
|
447
560
|
.filter((e) => e.type === EventType.NET_REQUEST)
|
|
448
561
|
.map((e) => asString(e.data['id']))
|
|
@@ -460,9 +573,9 @@ function findWindowContradictions(allEvents, options) {
|
|
|
460
573
|
// the finding says which URLs, so an agent reading it can see what Reticle chose to ignore.
|
|
461
574
|
detail: [
|
|
462
575
|
inFlight.map((p) => describe(p.call)).join('; '),
|
|
463
|
-
...(0 ===
|
|
576
|
+
...(0 === ignoredForeign.length
|
|
464
577
|
? []
|
|
465
|
-
: [`ignored as dev tooling: ${
|
|
578
|
+
: [`ignored as dev tooling or third-party: ${ignoredForeign.join(', ')}`]),
|
|
466
579
|
].join(' — '),
|
|
467
580
|
});
|
|
468
581
|
}
|
|
@@ -36,13 +36,15 @@ export declare function declaredExpectations(predicate: Predicate | undefined):
|
|
|
36
36
|
*
|
|
37
37
|
* The unread-body clause exists for the case where the body is the ONLY channel that could have
|
|
38
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,
|
|
40
|
-
*
|
|
41
|
-
* agent went to enable capture instead
|
|
39
|
+
* 200. A declared string on screen, store path, signal, route, or an element located by role /
|
|
40
|
+
* name / testid that held is a different channel, and grading `unknown` there costs a real
|
|
41
|
+
* verdict (measured: a 201 plus the unique row, unread body, agent went to enable capture instead
|
|
42
|
+
* of finishing the drive).
|
|
42
43
|
*
|
|
43
44
|
* Same conservatism as `declaredExpectations`: only the top level and `allOf`. An `anyOf` branch
|
|
44
45
|
* 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
|
+
* success that was the branch that actually matched. An `absent` element is not a consequence the
|
|
47
|
+
* body cannot own.
|
|
46
48
|
*/
|
|
47
49
|
export declare function declaresBodyIndependentChannel(predicate: Predicate | undefined): boolean;
|
|
48
50
|
/**
|
|
@@ -55,5 +57,6 @@ export declare function declaresBodyIndependentChannel(predicate: Predicate | un
|
|
|
55
57
|
export declare function matchesDeclaredFailure(call: {
|
|
56
58
|
method: string;
|
|
57
59
|
url: string;
|
|
60
|
+
matchUrl?: string;
|
|
58
61
|
status: number | undefined;
|
|
59
62
|
}, declared: readonly DeclaredNetFailure[]): boolean;
|
package/dist/events/declared.js
CHANGED
|
@@ -58,13 +58,15 @@ export function declaredExpectations(predicate) {
|
|
|
58
58
|
*
|
|
59
59
|
* The unread-body clause exists for the case where the body is the ONLY channel that could have
|
|
60
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,
|
|
62
|
-
*
|
|
63
|
-
* agent went to enable capture instead
|
|
61
|
+
* 200. A declared string on screen, store path, signal, route, or an element located by role /
|
|
62
|
+
* name / testid that held is a different channel, and grading `unknown` there costs a real
|
|
63
|
+
* verdict (measured: a 201 plus the unique row, unread body, agent went to enable capture instead
|
|
64
|
+
* of finishing the drive).
|
|
64
65
|
*
|
|
65
66
|
* Same conservatism as `declaredExpectations`: only the top level and `allOf`. An `anyOf` branch
|
|
66
67
|
* 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
|
+
* success that was the branch that actually matched. An `absent` element is not a consequence the
|
|
69
|
+
* body cannot own.
|
|
68
70
|
*/
|
|
69
71
|
export function declaresBodyIndependentChannel(predicate) {
|
|
70
72
|
if (predicate === undefined)
|
|
@@ -77,9 +79,15 @@ export function declaresBodyIndependentChannel(predicate) {
|
|
|
77
79
|
return true !== p.absent;
|
|
78
80
|
case PredicateKind.SIGNAL:
|
|
79
81
|
case PredicateKind.STATE:
|
|
82
|
+
case PredicateKind.ROUTE:
|
|
80
83
|
return true;
|
|
81
84
|
case PredicateKind.ELEMENT:
|
|
82
|
-
return true !== p.absent &&
|
|
85
|
+
return (true !== p.absent &&
|
|
86
|
+
(p.query.value !== undefined ||
|
|
87
|
+
p.query.text !== undefined ||
|
|
88
|
+
p.query.role !== undefined ||
|
|
89
|
+
p.query.name !== undefined ||
|
|
90
|
+
p.query.testid !== undefined));
|
|
83
91
|
default:
|
|
84
92
|
return false;
|
|
85
93
|
}
|
|
@@ -97,7 +105,8 @@ export function matchesDeclaredFailure(call, declared) {
|
|
|
97
105
|
return declared.some((d) => {
|
|
98
106
|
if (d.method !== undefined && d.method.toUpperCase() !== call.method.toUpperCase())
|
|
99
107
|
return false;
|
|
100
|
-
|
|
108
|
+
const haystack = call.matchUrl ?? call.url;
|
|
109
|
+
if (d.urlContains !== undefined && !haystack.includes(d.urlContains))
|
|
101
110
|
return false;
|
|
102
111
|
if (d.status !== undefined && d.status !== call.status)
|
|
103
112
|
return false;
|
|
@@ -28,7 +28,15 @@ import { ContradictionKind, EventType, MUTATING_METHODS } from '@reticlehq/core'
|
|
|
28
28
|
* - values are compared NORMALISED (trimmed, case-folded, numbers as numbers), so `FR` vs `fr` and
|
|
29
29
|
* `1` vs `1.0` stay silent while `fr` vs `en` speaks;
|
|
30
30
|
* - if the key appears anywhere in the response carrying the requested value, it is treated as
|
|
31
|
-
* applied — an envelope that echoes both the old and the new value is not a dropped write
|
|
31
|
+
* applied — an envelope that echoes both the old and the new value is not a dropped write;
|
|
32
|
+
* - the response must look like a restatement of the request before any key is compared. Reported
|
|
33
|
+
* from the field: a command bus POSTed `{command:'chat.send', ...}` and the server answered with
|
|
34
|
+
* the current viewer snapshot. The message arrived over the socket and rendered. `id` in the
|
|
35
|
+
* snapshot meant the viewer, `id` in the request meant the message, and this kind fired. A
|
|
36
|
+
* snapshot that shares a key name is not an echo. A discriminator the request carries that the
|
|
37
|
+
* response does not, or a fat body that shares one coincidental key, is skipped. The existing
|
|
38
|
+
* single-value / not-echoed-at-all guards still trade false negatives away — this narrows on
|
|
39
|
+
* shape, not on value types.
|
|
32
40
|
*
|
|
33
41
|
* The residue after those filters is a field the caller set, the server echoed back, and the echoed
|
|
34
42
|
* value is a genuinely different value. That is worth one line of an agent's attention.
|
|
@@ -94,6 +102,60 @@ function scalarsByKey(body, into = new Map(), depth = 0) {
|
|
|
94
102
|
}
|
|
95
103
|
const OK_MIN = 200;
|
|
96
104
|
const OK_MAX = 300;
|
|
105
|
+
/**
|
|
106
|
+
* Body keys that name the operation rather than a field being persisted. A command bus puts one of
|
|
107
|
+
* these on the request; a snapshot of the current viewer does not echo it. Their absence on the
|
|
108
|
+
* response is the cheapest honest signal that the body is not a restatement of the write.
|
|
109
|
+
*/
|
|
110
|
+
const EchoDiscriminator = {
|
|
111
|
+
COMMAND: 'command',
|
|
112
|
+
ACTION: 'action',
|
|
113
|
+
OP: 'op',
|
|
114
|
+
PROCEDURE: 'procedure',
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* A response carrying this many times more scalar keys than the request, overlapping on fewer than
|
|
118
|
+
* `MIN_SHARED_KEYS`, is a snapshot that happened to reuse a name — not an echo of the write.
|
|
119
|
+
*/
|
|
120
|
+
const SNAPSHOT_KEY_RATIO = 3;
|
|
121
|
+
const MIN_SHARED_KEYS = 2;
|
|
122
|
+
/**
|
|
123
|
+
* True when the response looks like a restatement of the request, so overlapping key names can be
|
|
124
|
+
* read as an echo rather than a coincidence.
|
|
125
|
+
*
|
|
126
|
+
* Half the request's comparable keys must appear in the response. That keeps a partial echo
|
|
127
|
+
* (`{density, locale}` answered with only `locale`) in scope, and drops a three-field command whose
|
|
128
|
+
* snapshot shares one name. A request that names an operation the response does not repeat is not
|
|
129
|
+
* an echo at all, regardless of any other overlap.
|
|
130
|
+
*/
|
|
131
|
+
function responseRestatesRequest(asked, echoed) {
|
|
132
|
+
const comparable = [];
|
|
133
|
+
for (const [key, wanted] of asked) {
|
|
134
|
+
if (1 === wanted.size)
|
|
135
|
+
comparable.push(key);
|
|
136
|
+
}
|
|
137
|
+
if (0 === comparable.length)
|
|
138
|
+
return false;
|
|
139
|
+
let present = 0;
|
|
140
|
+
for (const key of comparable) {
|
|
141
|
+
if (echoed.has(key))
|
|
142
|
+
present += 1;
|
|
143
|
+
}
|
|
144
|
+
let requestHasDiscriminator = false;
|
|
145
|
+
let responseHasDiscriminator = false;
|
|
146
|
+
for (const key of Object.values(EchoDiscriminator)) {
|
|
147
|
+
if (asked.has(key))
|
|
148
|
+
requestHasDiscriminator = true;
|
|
149
|
+
if (echoed.has(key))
|
|
150
|
+
responseHasDiscriminator = true;
|
|
151
|
+
}
|
|
152
|
+
if (requestHasDiscriminator && !responseHasDiscriminator)
|
|
153
|
+
return false;
|
|
154
|
+
if (echoed.size > comparable.length * SNAPSHOT_KEY_RATIO && present < MIN_SHARED_KEYS) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return present * 2 >= comparable.length;
|
|
158
|
+
}
|
|
97
159
|
/**
|
|
98
160
|
* Contradictions for writes whose response echoes a different value than the request asked for.
|
|
99
161
|
*
|
|
@@ -133,19 +195,7 @@ export function findEchoMismatches(events, actionSince) {
|
|
|
133
195
|
continue;
|
|
134
196
|
const echoed = scalarsByKey(response);
|
|
135
197
|
const asked = scalarsByKey(request);
|
|
136
|
-
|
|
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)
|
|
198
|
+
if (!responseRestatesRequest(asked, echoed))
|
|
149
199
|
continue;
|
|
150
200
|
const dropped = [];
|
|
151
201
|
for (const [key, wanted] of asked) {
|
|
@@ -32,6 +32,14 @@ export declare function matchNet(e: ReticleEvent, method: string | undefined, ur
|
|
|
32
32
|
* pending: true }` so a numeric `status` filter excludes them but `urlContains`/`method` match.
|
|
33
33
|
*/
|
|
34
34
|
export declare function reconcileNet(events: ReticleEvent[]): ReticleEvent[];
|
|
35
|
+
/**
|
|
36
|
+
* Strip the grader-only raw URL before an event is rendered to the agent.
|
|
37
|
+
*
|
|
38
|
+
* `urlRaw` exists so `urlContains` can match a path redaction rewrote. It is a secret-bearing field
|
|
39
|
+
* by construction (the whole reason `url` was redacted) and must never appear in observe, evidence,
|
|
40
|
+
* or a network listing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function withoutUrlRaw(event: unknown): unknown;
|
|
35
43
|
/** Compact network-call summary for reticle_network output — drops event plumbing (t, type,
|
|
36
44
|
* sessionId, ref, id, initiator, ok) the agent never needs, keeping only method/url/status/ms.
|
|
37
45
|
* This is the bulk of the token cost: raw ReticleEvent objects are ~5x larger than this. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EventType } from '@reticlehq/core';
|
|
1
|
+
import { EventType, URL_RAW, urlForMatch } from '@reticlehq/core';
|
|
2
2
|
import { asString, asNumber } from '../tools/tools-helpers.js';
|
|
3
3
|
import { mergeNetworkDetail } from '../input/network-detail.js';
|
|
4
4
|
/**
|
|
@@ -55,7 +55,7 @@ export function matchNet(e, method, urlContains, status, ok) {
|
|
|
55
55
|
if (method !== undefined && asString(d['method'])?.toUpperCase() !== method.toUpperCase()) {
|
|
56
56
|
return false;
|
|
57
57
|
}
|
|
58
|
-
if (urlContains !== undefined && !(
|
|
58
|
+
if (urlContains !== undefined && !urlForMatch(d).includes(urlContains)) {
|
|
59
59
|
return false;
|
|
60
60
|
}
|
|
61
61
|
if (status !== undefined && asNumber(d['status']) !== status)
|
|
@@ -86,6 +86,23 @@ export function reconcileNet(events) {
|
|
|
86
86
|
.map((e) => ({ ...e, data: { ...e.data, status: 'pending', pending: true } }));
|
|
87
87
|
return [...completed, ...unresolved].sort((a, b) => a.t - b.t);
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Strip the grader-only raw URL before an event is rendered to the agent.
|
|
91
|
+
*
|
|
92
|
+
* `urlRaw` exists so `urlContains` can match a path redaction rewrote. It is a secret-bearing field
|
|
93
|
+
* by construction (the whole reason `url` was redacted) and must never appear in observe, evidence,
|
|
94
|
+
* or a network listing.
|
|
95
|
+
*/
|
|
96
|
+
export function withoutUrlRaw(event) {
|
|
97
|
+
if (typeof event !== 'object' || null === event)
|
|
98
|
+
return event;
|
|
99
|
+
const rec = event;
|
|
100
|
+
const data = rec['data'];
|
|
101
|
+
if (typeof data !== 'object' || null === data || !(URL_RAW in data))
|
|
102
|
+
return event;
|
|
103
|
+
const { [URL_RAW]: _raw, ...rest } = data;
|
|
104
|
+
return { ...rec, data: rest };
|
|
105
|
+
}
|
|
89
106
|
export function projectNetCall(e, includeBodies = true) {
|
|
90
107
|
const status = e.data['status'];
|
|
91
108
|
const ms = asNumber(e.data['durationMs']);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { EventType, PredicateKind, StreamDirection, isDevToolingUrl, } from '@reticlehq/core';
|
|
1
|
+
import { EventType, PredicateKind, StreamDirection, isDevToolingUrl, urlForMatch, REDACTED_VALUE, } from '@reticlehq/core';
|
|
2
2
|
import { describeObserved } from './observed-in-window.js';
|
|
3
|
+
import { withoutUrlRaw } from './event-filters.js';
|
|
3
4
|
// The predicate SHAPE — the discriminated union, its aliases and its zod schema — lives in
|
|
4
5
|
// predicate-schema.ts. Re-exported here so every existing importer of this module is unaffected:
|
|
5
6
|
// the two halves are one public surface, split only because the file outgrew the size backstop.
|
|
@@ -140,6 +141,26 @@ function describeCall(e) {
|
|
|
140
141
|
const status = num(e.data['status']);
|
|
141
142
|
return status === undefined ? head : `${head} → ${String(status)}`;
|
|
142
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* What a miss should say when the displayed URL was redacted. Matching uses `urlRaw` when present;
|
|
146
|
+
* an older SDK has no copy, and this is the sentence both reporters asked for.
|
|
147
|
+
*/
|
|
148
|
+
const REDACTED_PATH_HINT = 'this path segment was redacted — the literal you matched may be here, try bodyContains';
|
|
149
|
+
function observedNetCalls(events, urlContains) {
|
|
150
|
+
const calls = events.filter((e) => e.type === EventType.NET_REQUEST);
|
|
151
|
+
const base = describeObserved('calls', calls.map(describeCall));
|
|
152
|
+
if (urlContains === undefined)
|
|
153
|
+
return base;
|
|
154
|
+
const encoded = encodeURIComponent(REDACTED_VALUE);
|
|
155
|
+
const redacted = calls.some((e) => {
|
|
156
|
+
const url = str(e.data['url']) ?? '';
|
|
157
|
+
return url.includes(REDACTED_VALUE) || url.includes(encoded);
|
|
158
|
+
});
|
|
159
|
+
return redacted ? `${base}; ${REDACTED_PATH_HINT}` : base;
|
|
160
|
+
}
|
|
161
|
+
function netEvidence(data) {
|
|
162
|
+
return withoutUrlRaw({ data }).data;
|
|
163
|
+
}
|
|
143
164
|
/**
|
|
144
165
|
* How much of a response body a failure may quote. Enough to see the value that differed on the
|
|
145
166
|
* bodies this field is used against (a JSON answer), and not a whole payload in every verdict.
|
|
@@ -314,6 +335,12 @@ export function evalNet(events, p) {
|
|
|
314
335
|
* a UI wiring bug that did not exist, when the defect was the value the server answered with.
|
|
315
336
|
*/
|
|
316
337
|
let bodyMismatch;
|
|
338
|
+
/**
|
|
339
|
+
* The prefix of a TRUNCATED body the needle was not found in. Held apart from `bodyMismatch`
|
|
340
|
+
* because the two are different verdicts: a full body without the needle decides the assertion,
|
|
341
|
+
* a truncated one cannot (#614).
|
|
342
|
+
*/
|
|
343
|
+
let truncatedBody;
|
|
317
344
|
const matches = events.filter((e) => {
|
|
318
345
|
if (e.type !== EventType.NET_REQUEST || e.t < since)
|
|
319
346
|
return false;
|
|
@@ -321,7 +348,7 @@ export function evalNet(events, p) {
|
|
|
321
348
|
if (p.method !== undefined && str(d['method'])?.toUpperCase() !== p.method.toUpperCase()) {
|
|
322
349
|
return false;
|
|
323
350
|
}
|
|
324
|
-
if (p.urlContains !== undefined && !(
|
|
351
|
+
if (p.urlContains !== undefined && !urlForMatch(d).includes(p.urlContains)) {
|
|
325
352
|
return false;
|
|
326
353
|
}
|
|
327
354
|
if (p.status !== undefined) {
|
|
@@ -350,6 +377,15 @@ export function evalNet(events, p) {
|
|
|
350
377
|
return false;
|
|
351
378
|
}
|
|
352
379
|
if (!response.includes(p.bodyContains)) {
|
|
380
|
+
// A needle missing from a body we only hold the FIRST N BYTES of is undecidable, not
|
|
381
|
+
// absent: the rest of the response was never recorded, so nothing here can say whether it
|
|
382
|
+
// was in there (#614). Grading it `pass: false` with "the response value is what differed"
|
|
383
|
+
// is the inversion the honesty rules exist to prevent — an unknown reported as decided,
|
|
384
|
+
// against a response that was very likely correct.
|
|
385
|
+
if (true === d['responseBodyTruncated']) {
|
|
386
|
+
truncatedBody ??= response;
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
353
389
|
bodyMismatch ??= response;
|
|
354
390
|
return false;
|
|
355
391
|
}
|
|
@@ -377,6 +413,18 @@ export function evalNet(events, p) {
|
|
|
377
413
|
assertion: 'net.bodyContains',
|
|
378
414
|
};
|
|
379
415
|
}
|
|
416
|
+
// Ranked ABOVE the mismatch branch: when both a truncated and a full body missed the needle,
|
|
417
|
+
// the honest verdict is the undecidable one. Deciding on the full body would report a failure
|
|
418
|
+
// the truncated call may well contradict.
|
|
419
|
+
if (truncatedBody !== undefined && 0 === matches.length) {
|
|
420
|
+
return {
|
|
421
|
+
pass: false,
|
|
422
|
+
inconclusive: `a call matching ${describeNetFilter(p)} was answered with a body that was TRUNCATED before it was recorded, and ${JSON.stringify(p.bodyContains)} is not in the part that was kept — so this is undecidable, not a failure. Raise the capture cap or assert on something inside the recorded prefix`,
|
|
423
|
+
observed: `the first ${String(truncatedBody.length)} characters of a truncated response body ${JSON.stringify(clipBody(truncatedBody))}`,
|
|
424
|
+
expected: `a response body containing ${JSON.stringify(p.bodyContains)}`,
|
|
425
|
+
assertion: 'net.bodyContains',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
380
428
|
if (bodyMismatch !== undefined && 0 === matches.length) {
|
|
381
429
|
// The call is there and its body is there; only the VALUE differs. Counting it as zero matches
|
|
382
430
|
// points at the wiring, which is the one place the defect is not.
|
|
@@ -418,19 +466,19 @@ export function evalNet(events, p) {
|
|
|
418
466
|
pass: false,
|
|
419
467
|
failureReason: reason,
|
|
420
468
|
inconclusive: reason,
|
|
421
|
-
observed:
|
|
469
|
+
observed: observedNetCalls(events, p.urlContains),
|
|
422
470
|
expected: `at least one call matching ${describeNetFilter(p)}`,
|
|
423
471
|
assertion: 'net.unobserved-channel',
|
|
424
472
|
};
|
|
425
473
|
}
|
|
426
474
|
return hit !== undefined
|
|
427
|
-
? { pass: true, evidence: hit.data }
|
|
475
|
+
? { pass: true, evidence: netEvidence(hit.data) }
|
|
428
476
|
: {
|
|
429
477
|
pass: false,
|
|
430
478
|
failureReason: `no network call matched ${JSON.stringify(p)}`,
|
|
431
479
|
// Same reasoning as the signal miss: "no matching call" cannot be told apart from "the app
|
|
432
480
|
// made no calls at all", and those need different fixes.
|
|
433
|
-
observed:
|
|
481
|
+
observed: observedNetCalls(events, p.urlContains),
|
|
434
482
|
expected: `at least one call matching ${JSON.stringify(p)}`,
|
|
435
483
|
assertion: 'net.present',
|
|
436
484
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reason this predicate cannot be evaluated, or undefined when it can.
|
|
3
|
+
*
|
|
4
|
+
* Only checks what is knowable WITHOUT the page — a locator that names fields the resolver drops is
|
|
5
|
+
* wrong whatever the DOM contains, which is what makes it safe to refuse before acting. Anything
|
|
6
|
+
* that depends on what is actually rendered is still decided after the action, where it belongs.
|
|
7
|
+
*/
|
|
8
|
+
export declare function unevaluablePredicateReason(predicate: unknown): string | undefined;
|