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