@reticlehq/server 2.9.0 → 2.11.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 +64 -26
- package/dist/capsule/capsule-store.d.ts +2 -2
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-verify.js +1 -1
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/dev/stale-issue-guard.d.ts +43 -0
- package/dist/dev/stale-issue-guard.js +75 -0
- package/dist/domain/domain-tools.js +3 -2
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +19 -1
- package/dist/events/contradictions.js +17 -2
- package/dist/events/echo-mismatch.d.ts +2 -2
- package/dist/events/predicate-asks.d.ts +17 -0
- package/dist/events/predicate-asks.js +26 -0
- package/dist/events/predicate-eval.js +211 -24
- package/dist/events/predicate-parse.js +9 -3
- package/dist/events/predicate-schema.d.ts +18 -0
- package/dist/events/predicate-schema.js +55 -0
- package/dist/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- package/dist/flows/flow-tools.js +34 -5
- package/dist/flows/predicate-to-expect.js +8 -0
- package/dist/flows/verify-change-tools.js +6 -2
- package/dist/honesty/blind-spots.d.ts +9 -0
- package/dist/honesty/blind-spots.js +17 -4
- package/dist/honesty/gap-ledger.d.ts +52 -0
- package/dist/honesty/gap-ledger.js +63 -0
- package/dist/honesty/instrumentation-gaps.d.ts +55 -0
- package/dist/honesty/instrumentation-gaps.js +21 -0
- package/dist/honesty/observability.d.ts +37 -0
- package/dist/honesty/observability.js +40 -0
- package/dist/honesty/verified.js +16 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +57 -0
- package/dist/init/mcp.d.ts +1 -2
- package/dist/init/mcp.js +4 -1
- package/dist/init/node-io.js +3 -0
- package/dist/init/run.d.ts +18 -0
- package/dist/init/run.js +34 -3
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +33 -10
- package/dist/journal/envelope.d.ts +24 -24
- package/dist/license/license.d.ts +76 -5
- package/dist/license/license.js +93 -29
- package/dist/mcp/mcp-proxy.d.ts +2 -27
- package/dist/mcp/mcp-proxy.js +4 -88
- package/dist/mcp/mcp.d.ts +18 -2
- package/dist/mcp/mcp.js +31 -10
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.js +1 -1
- package/dist/mcp/server-instructions.js +2 -2
- package/dist/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- package/dist/project/artifact-root.d.ts +83 -0
- package/dist/project/artifact-root.js +112 -0
- package/dist/project/memory-fs.d.ts +9 -0
- package/dist/project/memory-fs.js +88 -0
- package/dist/project/project-store.d.ts +17 -0
- package/dist/project/project-store.js +33 -0
- package/dist/project/remember-project.d.ts +24 -0
- package/dist/project/remember-project.js +40 -0
- package/dist/project/session-root.d.ts +15 -0
- package/dist/project/session-root.js +31 -0
- package/dist/runs/run-tools.js +2 -1
- package/dist/session/lease-availability.d.ts +40 -0
- package/dist/session/lease-availability.js +43 -0
- package/dist/session/no-session-diagnosis.d.ts +17 -0
- package/dist/session/no-session-diagnosis.js +41 -8
- package/dist/session/no-session-watch.js +7 -0
- package/dist/session/session-readiness.js +1 -1
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +9 -0
- package/dist/session/stall-clock.d.ts +18 -0
- package/dist/session/stall-clock.js +48 -0
- package/dist/telemetry/daemon-telemetry.js +16 -22
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- 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 +34 -5
- package/dist/tools/act-tools.js +34 -4
- package/dist/tools/assert-verdict.d.ts +2 -0
- package/dist/tools/assert-verdict.js +24 -1
- package/dist/tools/contract-tools.js +5 -3
- package/dist/tools/coverage-tools.js +33 -0
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.js +1 -1
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +10 -5
- package/dist/tools/observe-tools.js +8 -2
- package/dist/tools/tool-kit.d.ts +46 -4
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.js +2 -3
- package/dist/tools/tools.js +16 -1
- package/docs/README.md +1 -0
- package/docs/agent-cheatsheet.md +2 -2
- package/docs/capabilities.mdx +4 -4
- package/docs/cli/hunt.mdx +2 -2
- package/docs/deploy-checks.md +2 -2
- package/docs/docs.json +2 -2
- package/docs/enterprise.md +9 -3
- package/docs/flows.md +4 -4
- package/docs/gates.md +1 -1
- package/docs/license-activation.mdx +145 -0
- package/docs/recipes.mdx +2 -2
- package/docs/telemetry-contract.md +18 -12
- package/docs/telemetry-events.mdx +1 -34
- package/docs/telemetry.md +0 -1
- package/docs/tools-overview.mdx +1 -1
- package/docs/tools-session-and-feedback.mdx +5 -0
- package/docs/tools-tools-and-run.mdx +15 -15
- package/docs/usage.md +3 -3
- package/package.json +2 -2
- package/dist/telemetry/instrumentation-stall.d.ts +0 -50
- package/dist/telemetry/instrumentation-stall.js +0 -90
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { log } from '../log.js';
|
|
2
|
+
const FOLDS = [];
|
|
3
|
+
/**
|
|
4
|
+
* Add a rule to every verdict this engine produces. Returns the undo.
|
|
5
|
+
*
|
|
6
|
+
* The undo is not a convenience: without it a registry is process-global state that tests cannot
|
|
7
|
+
* clean up, so each one starts depending on the order it ran in.
|
|
8
|
+
*/
|
|
9
|
+
export function registerContradictionFold(fold) {
|
|
10
|
+
FOLDS.push(fold);
|
|
11
|
+
return () => {
|
|
12
|
+
const at = FOLDS.indexOf(fold);
|
|
13
|
+
if (at !== -1)
|
|
14
|
+
FOLDS.splice(at, 1);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** What is registered right now. Exported so a guard can assert the registry is empty by default. */
|
|
18
|
+
export function registeredContradictionFolds() {
|
|
19
|
+
return [...FOLDS];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Run every registered rule, containing anything one of them throws.
|
|
23
|
+
*
|
|
24
|
+
* A registered fold is somebody else's code running inside every verdict path this package has. If a
|
|
25
|
+
* throw could propagate, one defect in a consumer's rule would take down `assert`, `act_and_wait`,
|
|
26
|
+
* `observe` and `crawl` at once — turning a missing finding into a dead engine. Contained and logged:
|
|
27
|
+
* the rules that did run still report, and the failure is visible rather than swallowed.
|
|
28
|
+
*/
|
|
29
|
+
export function runRegisteredFolds(events, options) {
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const fold of FOLDS) {
|
|
32
|
+
try {
|
|
33
|
+
found.push(...fold(events, options));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
log('contradiction_fold_failed', {
|
|
37
|
+
error: error instanceof Error ? error.message : String(error),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
@@ -19,7 +19,16 @@ import { type DeclaredNetFailure } from './declared.js';
|
|
|
19
19
|
* Pure: a window of events in, findings out. No session, no IO, no clock.
|
|
20
20
|
*/
|
|
21
21
|
export interface Contradiction {
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Typed `string`, not `ContradictionKind`, because the vocabulary is open at the EDGE.
|
|
24
|
+
*
|
|
25
|
+
* `ContradictionKind` enumerates what THIS package's rules emit, and every one of them is still
|
|
26
|
+
* checked against it — see `OwnContradiction`, which is what the folds below build. A rule
|
|
27
|
+
* registered by a consumer emits kinds this package has never heard of and must not have to add
|
|
28
|
+
* them here to be reportable: a shared enum is exactly how a consumer's private vocabulary ends up
|
|
29
|
+
* shipped in the free product by accident.
|
|
30
|
+
*/
|
|
31
|
+
kind: string;
|
|
23
32
|
/** What one channel asserted — the optimistic half. */
|
|
24
33
|
claim: string;
|
|
25
34
|
/** What the other channel asserted — the half that contradicts it. */
|
|
@@ -27,6 +36,15 @@ export interface Contradiction {
|
|
|
27
36
|
/** Concrete evidence, so the agent can go straight to the call or the control. */
|
|
28
37
|
detail: string;
|
|
29
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* A contradiction emitted by one of THIS package's rules — the kind is closed.
|
|
41
|
+
*
|
|
42
|
+
* Widening `Contradiction.kind` to `string` for the consumer seam would otherwise have made every
|
|
43
|
+
* emit site below accept a typo'd literal. This keeps them checked without closing the edge.
|
|
44
|
+
*/
|
|
45
|
+
export type OwnContradiction = Contradiction & {
|
|
46
|
+
kind: ContradictionKind;
|
|
47
|
+
};
|
|
30
48
|
export interface ContradictionOptions {
|
|
31
49
|
/** The action that opened this window, when one did. Enables the no-effect check. */
|
|
32
50
|
action?: string | undefined;
|
|
@@ -5,6 +5,7 @@ import { findEchoMismatches } from './echo-mismatch.js';
|
|
|
5
5
|
import { findUnitMismatches } from './unit-mismatch.js';
|
|
6
6
|
import { asNumber, asString } from '../tools/tools-helpers.js';
|
|
7
7
|
import { matchesDeclaredFailure } from './declared.js';
|
|
8
|
+
import { runRegisteredFolds } from './contradiction-folds.js';
|
|
8
9
|
function netCall(e) {
|
|
9
10
|
const status = asNumber(e.data['status']);
|
|
10
11
|
return {
|
|
@@ -255,7 +256,19 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
255
256
|
// claimed success" about an app that plainly reported a failure is true in outline and wrong in
|
|
256
257
|
// its reasoning, which is how a checker stops being believed.
|
|
257
258
|
const successSignals = signals.filter((name) => !ACKNOWLEDGED.test(name));
|
|
258
|
-
|
|
259
|
+
// An app that RETRACTED has not claimed success, whenever it fired the optimistic signal.
|
|
260
|
+
//
|
|
261
|
+
// The weaker UI rule below already consulted this and the sharper signal rule did not, so an app
|
|
262
|
+
// that announced "ack:requested", met a 500, and then correctly emitted "ack:failed" and rolled the
|
|
263
|
+
// row back was reported as explicitly asserting success — the strongest accusation this file makes,
|
|
264
|
+
// against code doing exactly the right thing. On a fixture suite the scenario's FIXED twin produced
|
|
265
|
+
// the same finding as the build that swallowed the failure, which makes the finding worthless on
|
|
266
|
+
// the one measurement that scores precision.
|
|
267
|
+
//
|
|
268
|
+
// Ordering cannot decide this: an optimistic UI legitimately fires its success signal BEFORE the
|
|
269
|
+
// response, so "the claim must follow the failure" would miss the real defect. What separates them
|
|
270
|
+
// is not when the app spoke, it is whether it took it back.
|
|
271
|
+
if (failed.length > 0 && successSignals.length > 0 && !failureAcknowledged(events)) {
|
|
259
272
|
found.push({
|
|
260
273
|
kind: ContradictionKind.SIGNAL_CONTRADICTED,
|
|
261
274
|
claim: `the app fired ${successSignals.map((s) => `"${s}"`).join(', ')}`,
|
|
@@ -341,5 +354,7 @@ export function findContradictions(allEvents, options = {}) {
|
|
|
341
354
|
});
|
|
342
355
|
}
|
|
343
356
|
}
|
|
344
|
-
|
|
357
|
+
// Consumer rules run LAST and over the same app-only window, so a service embedding this engine
|
|
358
|
+
// adds to the verdict rather than forking the file that produces it.
|
|
359
|
+
return [...found, ...runRegisteredFolds(events, options)];
|
|
345
360
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
-
import type {
|
|
2
|
+
import type { OwnContradiction } from './contradictions.js';
|
|
3
3
|
/**
|
|
4
4
|
* Contradictions for writes whose response echoes a different value than the request asked for.
|
|
5
5
|
*
|
|
@@ -7,4 +7,4 @@ import type { Contradiction } from './contradictions.js';
|
|
|
7
7
|
* action that opened this window was dispatched. A finding about a request that had already settled
|
|
8
8
|
* before the agent acted otherwise reads as a verdict on the change it just made.
|
|
9
9
|
*/
|
|
10
|
-
export declare function findEchoMismatches(events: readonly ReticleEvent[], actionSince?: number):
|
|
10
|
+
export declare function findEchoMismatches(events: readonly ReticleEvent[], actionSince?: number): OwnContradiction[];
|
|
@@ -0,0 +1,17 @@
|
|
|
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;
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
}
|
|
@@ -160,8 +160,111 @@ function describeNetFilter(p) {
|
|
|
160
160
|
const { kind: _kind, count: _count, since: _since, ...filter } = p;
|
|
161
161
|
return JSON.stringify(filter);
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* URL suffixes only the DOCUMENT fetches, which the network observer therefore never records.
|
|
165
|
+
*
|
|
166
|
+
* `network.ts` patches `fetch` and `XMLHttpRequest` and nothing else, so `<link rel=icon>`,
|
|
167
|
+
* `<link rel=manifest>`, stylesheets, fonts and `<img src>` are invisible to it. "No matching call"
|
|
168
|
+
* and "that class of call is not observed" are then indistinguishable, and the miss graded
|
|
169
|
+
* `assertion_failed` — a false RED. Reported from the field: an assert over `/favicon.ico`,
|
|
170
|
+
* `/site.webmanifest` and `/apple-touch-icon.png` returned `verified:"no"` while curl showed all
|
|
171
|
+
* three answering 200. A false negative here is worse than an unknown, because an agent that trusts
|
|
172
|
+
* it goes and "fixes" working code.
|
|
173
|
+
*
|
|
174
|
+
* Deliberately NOT here: `.js` and `.json`. Both are routinely fetched via `fetch`/XHR — a module
|
|
175
|
+
* preload and an API call can share a suffix — so downgrading them would hide real misses. This list
|
|
176
|
+
* is only the suffixes for which the document is the sole plausible initiator.
|
|
177
|
+
*
|
|
178
|
+
* This began as the smaller half of #447: it did not make these requests observable, it stopped
|
|
179
|
+
* Reticle claiming they did not happen. The other half, observing them via resource timing, has
|
|
180
|
+
* since landed in the browser SDK, so the two halves are no longer independent and this downgrade is
|
|
181
|
+
* now GATED: when the event stream carries at least one document-initiated record, the observer is
|
|
182
|
+
* demonstrably live and a miss over these suffixes is real evidence, graded a plain failure. Only a
|
|
183
|
+
* page whose stream holds no resource entry at all keeps the honest "cannot tell apart" answer,
|
|
184
|
+
* because there the observer may simply not exist.
|
|
185
|
+
*/
|
|
186
|
+
const DOCUMENT_ONLY_SUFFIXES = [
|
|
187
|
+
'.ico',
|
|
188
|
+
'.png',
|
|
189
|
+
'.jpg',
|
|
190
|
+
'.jpeg',
|
|
191
|
+
'.gif',
|
|
192
|
+
'.svg',
|
|
193
|
+
'.webp',
|
|
194
|
+
'.avif',
|
|
195
|
+
'.bmp',
|
|
196
|
+
'.css',
|
|
197
|
+
'.woff',
|
|
198
|
+
'.woff2',
|
|
199
|
+
'.ttf',
|
|
200
|
+
'.otf',
|
|
201
|
+
'.eot',
|
|
202
|
+
'.webmanifest',
|
|
203
|
+
];
|
|
204
|
+
/**
|
|
205
|
+
* Does this filter target a class of request the observer cannot see?
|
|
206
|
+
*
|
|
207
|
+
* Read off `urlContains` only, and only when the pattern ENDS in one of the suffixes — a filter of
|
|
208
|
+
* `/api/` that happens to contain `.css` somewhere in a query string is still an ordinary XHR
|
|
209
|
+
* target. Query strings and fragments are stripped first, since `favicon.ico?v=2` is the same asset.
|
|
210
|
+
*/
|
|
211
|
+
function targetsUnobservedChannel(p) {
|
|
212
|
+
if (p.urlContains === undefined)
|
|
213
|
+
return false;
|
|
214
|
+
const path = (p.urlContains.split('#')[0] ?? '').split('?')[0]?.toLowerCase() ?? '';
|
|
215
|
+
return DOCUMENT_ONLY_SUFFIXES.some((suffix) => path.endsWith(suffix));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Initiator types the browser SDK's resource-timing observer stamps onto document-initiated records
|
|
219
|
+
* (packages/browser/src/observers/network.ts). The patched transports sign themselves `fetch`,
|
|
220
|
+
* `xhr` or `beacon`, so any NET_REQUEST carrying one of these came from a `resource` entry, and its
|
|
221
|
+
* mere presence is proof the PerformanceObserver is alive on this page. Mirrors the wire; keep in
|
|
222
|
+
* sync with the browser package.
|
|
223
|
+
*/
|
|
224
|
+
const DOCUMENT_INITIATORS = new Set([
|
|
225
|
+
'link',
|
|
226
|
+
'css',
|
|
227
|
+
'img',
|
|
228
|
+
'script',
|
|
229
|
+
'manifest',
|
|
230
|
+
'other',
|
|
231
|
+
]);
|
|
232
|
+
/**
|
|
233
|
+
* Did the observer actually report a document-initiated load?
|
|
234
|
+
*
|
|
235
|
+
* This is the gate on the unobserved-channel downgrade above. Once subresource observation landed,
|
|
236
|
+
* a miss over `.ico`/`.css`/`.woff2` is no longer inherently unknowable: if the observer is live it
|
|
237
|
+
* would have seen the favicon load, so seeing none is evidence it never fired. But the observer is
|
|
238
|
+
* opt-in-by-engine, not guaranteed: on a renderer without `PerformanceObserver` (or before any
|
|
239
|
+
* resource entry exists) nothing distinguishes "not requested" from "not visible", and the honest
|
|
240
|
+
* answer stays inconclusive. Liveness is inferred from the same events being judged: one
|
|
241
|
+
* document-initiated record anywhere in the window proves the channel works.
|
|
242
|
+
*/
|
|
243
|
+
function observerSawSubresources(events) {
|
|
244
|
+
return events.some((e) => {
|
|
245
|
+
if (e.type !== EventType.NET_REQUEST)
|
|
246
|
+
return false;
|
|
247
|
+
const initiator = str(e.data['initiator']);
|
|
248
|
+
return initiator !== undefined && DOCUMENT_INITIATORS.has(initiator);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** The sentence both zero-match branches hand to `inconclusive`. */
|
|
252
|
+
function unobservedChannelReason(p) {
|
|
253
|
+
return (`no call matched ${describeNetFilter(p)}, and no document-initiated load was recorded either, ` +
|
|
254
|
+
`so this page exposed no resource timing to read: Reticle observes fetch and XMLHttpRequest ` +
|
|
255
|
+
`directly, while a request the document initiates (<link rel=icon|manifest|preload>, a ` +
|
|
256
|
+
`stylesheet, a font, <img src>) leaves no record, and that state cannot be told apart from the ` +
|
|
257
|
+
`request having been made. ` +
|
|
258
|
+
`Nothing here says the app is wrong. Check it outside the browser, or assert something the ` +
|
|
259
|
+
`document does not fetch on its own`);
|
|
260
|
+
}
|
|
163
261
|
export function evalNet(events, p) {
|
|
164
262
|
const since = p.since ?? 0;
|
|
263
|
+
/**
|
|
264
|
+
* Computed once: the gate every zero-match downgrade below reads. True means the resource-timing
|
|
265
|
+
* observer is demonstrably live in this window, so a miss is evidence rather than blindness.
|
|
266
|
+
*/
|
|
267
|
+
const sawSubresources = observerSawSubresources(events);
|
|
165
268
|
/**
|
|
166
269
|
* Did any call match everything EXCEPT the body assertion, while carrying no recorded body?
|
|
167
270
|
*
|
|
@@ -171,6 +274,8 @@ export function evalNet(events, p) {
|
|
|
171
274
|
* because it is the same pass over the same events.
|
|
172
275
|
*/
|
|
173
276
|
let matchedButUnrecorded = false;
|
|
277
|
+
/** A call matched url/method but carried NO readable status (document-initiated subresource). */
|
|
278
|
+
let unobservableStatus = false;
|
|
174
279
|
/**
|
|
175
280
|
* The response body of a call that matched everything EXCEPT the body assertion, and HAD one.
|
|
176
281
|
*
|
|
@@ -190,8 +295,19 @@ export function evalNet(events, p) {
|
|
|
190
295
|
if (p.urlContains !== undefined && !(str(d['url']) ?? '').includes(p.urlContains)) {
|
|
191
296
|
return false;
|
|
192
297
|
}
|
|
193
|
-
if (p.status !== undefined
|
|
194
|
-
|
|
298
|
+
if (p.status !== undefined) {
|
|
299
|
+
const status = num(d['status']);
|
|
300
|
+
if (status === undefined) {
|
|
301
|
+
// Document-initiated subresources (link/css/img/manifest via resource timing) carry no
|
|
302
|
+
// readable status on engines without responseStatus. A failed assertion here would read
|
|
303
|
+
// "your change is broken" and send the caller to fix working code — the exact false
|
|
304
|
+
// negative the oracle exists to prevent. Downgrade to unknown instead.
|
|
305
|
+
unobservableStatus = true;
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
if (status !== p.status)
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
195
311
|
if (p.ok !== undefined && callSucceeded(d) !== p.ok)
|
|
196
312
|
return false;
|
|
197
313
|
if (p.bodyContains !== undefined) {
|
|
@@ -211,6 +327,18 @@ export function evalNet(events, p) {
|
|
|
211
327
|
}
|
|
212
328
|
return true;
|
|
213
329
|
});
|
|
330
|
+
if (unobservableStatus && 0 === matches.length) {
|
|
331
|
+
// The url/method matched a document-initiated subresource whose engine could not read a status.
|
|
332
|
+
// "No matching call" would be a lie in both directions: it may have succeeded, it may have 404'd
|
|
333
|
+
// on exactly the path mistake the caller is hunting. Say the truth — not observable here.
|
|
334
|
+
return {
|
|
335
|
+
pass: false,
|
|
336
|
+
inconclusive: `a document-initiated request matching ${describeNetFilter(p)} was observed, but this engine does not expose its status code (resource timing without responseStatus) — assert on the element or route instead, or check the network tab`,
|
|
337
|
+
observed: 'a matching request with no readable status',
|
|
338
|
+
expected: `a status of ${String(p.status)} on ${JSON.stringify(p.urlContains ?? '*')}`,
|
|
339
|
+
assertion: 'net.unobservable-status',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
214
342
|
if (matchedButUnrecorded && 0 === matches.length) {
|
|
215
343
|
return {
|
|
216
344
|
pass: false,
|
|
@@ -237,19 +365,37 @@ export function evalNet(events, p) {
|
|
|
237
365
|
if (p.count !== undefined) {
|
|
238
366
|
return matches.length === p.count
|
|
239
367
|
? { pass: true, evidence: { matched: matches.length } }
|
|
240
|
-
:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
368
|
+
: 0 === matches.length && targetsUnobservedChannel(p) && !sawSubresources
|
|
369
|
+
? {
|
|
370
|
+
pass: false,
|
|
371
|
+
failureReason: unobservedChannelReason(p),
|
|
372
|
+
inconclusive: unobservedChannelReason(p),
|
|
373
|
+
assertion: 'net.count',
|
|
374
|
+
}
|
|
375
|
+
: {
|
|
376
|
+
pass: false,
|
|
377
|
+
// Monotonic: more matches than asked for can never become exactly the number asked for.
|
|
378
|
+
// This is the double-submit — two writes 59ms apart — so the finding that matters most was
|
|
379
|
+
// also the one that took the caller's whole budget to report.
|
|
380
|
+
...(matches.length > p.count ? { decided: true } : {}),
|
|
381
|
+
failureReason: `expected ${String(p.count)} network call(s) matching ${describeNetFilter(p)}, saw ${String(matches.length)}`,
|
|
382
|
+
observed: `${String(matches.length)} matching network call(s)`,
|
|
383
|
+
expected: `exactly ${String(p.count)} matching ${describeNetFilter(p)}`,
|
|
384
|
+
assertion: 'net.count',
|
|
385
|
+
};
|
|
251
386
|
}
|
|
252
387
|
const hit = matches[0];
|
|
388
|
+
if (hit === undefined && targetsUnobservedChannel(p) && !sawSubresources) {
|
|
389
|
+
const reason = unobservedChannelReason(p);
|
|
390
|
+
return {
|
|
391
|
+
pass: false,
|
|
392
|
+
failureReason: reason,
|
|
393
|
+
inconclusive: reason,
|
|
394
|
+
observed: describeObserved('calls', events.filter((e) => e.type === EventType.NET_REQUEST).map(describeCall)),
|
|
395
|
+
expected: `at least one call matching ${describeNetFilter(p)}`,
|
|
396
|
+
assertion: 'net.unobserved-channel',
|
|
397
|
+
};
|
|
398
|
+
}
|
|
253
399
|
return hit !== undefined
|
|
254
400
|
? { pass: true, evidence: hit.data }
|
|
255
401
|
: {
|
|
@@ -296,25 +442,66 @@ export function evalConsole(events, p) {
|
|
|
296
442
|
return isErr;
|
|
297
443
|
return e.type === CONSOLE_LEVEL_TYPE[p.level];
|
|
298
444
|
});
|
|
445
|
+
// A text match narrows the population to entries whose captured message contains the substring.
|
|
446
|
+
// With `absent: true` this is the whole point: "THIS message did not appear", not "no messages
|
|
447
|
+
// appeared" — the difference between a regression check and a fragile one that any unrelated
|
|
448
|
+
// warning anywhere in the app breaks.
|
|
449
|
+
const wanted = 'contains' in p ? p.contains : undefined;
|
|
450
|
+
// Captured messages are strings (stringifyArgs in the browser observer), but a malformed or
|
|
451
|
+
// foreign event must not crash the evaluator: non-strings stringify defensively, and objects
|
|
452
|
+
// go through JSON.stringify rather than a default toString that would print '[object Object]'.
|
|
453
|
+
const asText = (v) => {
|
|
454
|
+
if ('string' === typeof v)
|
|
455
|
+
return v;
|
|
456
|
+
try {
|
|
457
|
+
return JSON.stringify(v) ?? '';
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return '';
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const matching = wanted !== undefined
|
|
464
|
+
? matches.filter((e) => asText(e.data['message']).includes(wanted))
|
|
465
|
+
: matches;
|
|
299
466
|
if (true === p.absent) {
|
|
300
|
-
|
|
467
|
+
if (wanted !== undefined && 0 === matching.length && matches.length > 0) {
|
|
468
|
+
// Other entries exist but none carries the substring: exactly the pass an absence-with-match
|
|
469
|
+
// asserts. Name both counts so the caller can tell this from a silent window.
|
|
470
|
+
return {
|
|
471
|
+
pass: true,
|
|
472
|
+
evidence: { absent: true, contains: wanted },
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
return 0 === matching.length
|
|
301
476
|
? { pass: true, evidence: { absent: true } }
|
|
302
477
|
: {
|
|
303
478
|
pass: false,
|
|
304
|
-
failureReason:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
479
|
+
failureReason: wanted !== undefined
|
|
480
|
+
? `expected no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} but found ${String(matching.length)}`
|
|
481
|
+
: `expected no ${p.level ?? 'console'} entries but found ${String(matches.length)}`,
|
|
482
|
+
observed: wanted !== undefined
|
|
483
|
+
? `${String(matching.length)} ${p.level ?? 'console'} entr${1 === matching.length ? 'y' : 'ies'} containing ${JSON.stringify(wanted)}`
|
|
484
|
+
: `${String(matches.length)} ${p.level ?? 'console'} entr${1 === matches.length ? 'y' : 'ies'}`,
|
|
485
|
+
expected: wanted !== undefined
|
|
486
|
+
? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)}`
|
|
487
|
+
: `no ${p.level ?? 'console'} entries`,
|
|
488
|
+
assertion: wanted !== undefined ? 'console.absent-contains' : 'console.absent',
|
|
489
|
+
evidence: matching.map((e) => e.data),
|
|
309
490
|
};
|
|
310
491
|
}
|
|
311
|
-
return
|
|
312
|
-
? { pass: true, evidence:
|
|
492
|
+
return matching.length > 0
|
|
493
|
+
? { pass: true, evidence: matching.map((e) => e.data) }
|
|
313
494
|
: {
|
|
314
495
|
pass: false,
|
|
315
|
-
failureReason:
|
|
316
|
-
|
|
317
|
-
|
|
496
|
+
failureReason: wanted !== undefined
|
|
497
|
+
? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} found`
|
|
498
|
+
: `no ${p.level ?? 'console'} entries found`,
|
|
499
|
+
observed: wanted !== undefined
|
|
500
|
+
? `no ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)} in the window`
|
|
501
|
+
: `no ${p.level ?? 'console'} entries in the window`,
|
|
502
|
+
expected: wanted !== undefined
|
|
503
|
+
? `at least one ${p.level ?? 'console'} entry containing ${JSON.stringify(wanted)}`
|
|
504
|
+
: `at least one ${p.level ?? 'console'} entry`,
|
|
318
505
|
assertion: 'console.present',
|
|
319
506
|
};
|
|
320
507
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { PredicateKind } from '@reticlehq/core';
|
|
17
|
-
import { PredicateSchema, predicateFieldsFor } from './predicate-eval.js';
|
|
17
|
+
import { PredicateSchema, predicateFieldsFor, predicateNestedFieldsFor } from './predicate-eval.js';
|
|
18
18
|
/**
|
|
19
19
|
* One valid call PER KIND, because an example of another kind answers a question nobody asked.
|
|
20
20
|
*
|
|
@@ -90,7 +90,13 @@ export function parsePredicate(input) {
|
|
|
90
90
|
*/
|
|
91
91
|
function accepted(kind) {
|
|
92
92
|
const fields = predicateFieldsFor(kind);
|
|
93
|
-
if (0 < fields.length)
|
|
94
|
-
|
|
93
|
+
if (0 < fields.length) {
|
|
94
|
+
// A field whose value is an object is the one an agent cannot guess from its name alone, so
|
|
95
|
+
// expand it in the same breath rather than making the shape a second round trip.
|
|
96
|
+
const nested = Object.entries(predicateNestedFieldsFor(kind))
|
|
97
|
+
.map(([field, keys]) => ` ${field} accepts: ${keys.join(', ')}.`)
|
|
98
|
+
.join('');
|
|
99
|
+
return `${kind} accepts: ${fields.join(', ')}.${nested}`;
|
|
100
|
+
}
|
|
95
101
|
return `"${kind}" is not a predicate kind — use one of: ${Object.values(PredicateKind).join(', ')}.`;
|
|
96
102
|
}
|
|
@@ -38,6 +38,12 @@ export type Predicate = {
|
|
|
38
38
|
} | {
|
|
39
39
|
kind: typeof PredicateKind.CONSOLE;
|
|
40
40
|
level?: string;
|
|
41
|
+
/**
|
|
42
|
+
* A substring the captured message must contain. With `absent: true` this flips the meaning
|
|
43
|
+
* from "no console entries at all" to "THIS message did not appear" — the assertion people
|
|
44
|
+
* actually write regression checks for (deprecation warnings, no-op handler notices).
|
|
45
|
+
*/
|
|
46
|
+
contains?: string;
|
|
41
47
|
absent?: boolean;
|
|
42
48
|
since?: number;
|
|
43
49
|
} | {
|
|
@@ -97,3 +103,15 @@ export declare const PredicateSchema: z.ZodType<Predicate>;
|
|
|
97
103
|
* wall. Empty for a kind that is not in the union.
|
|
98
104
|
*/
|
|
99
105
|
export declare function predicateFieldsFor(kind: string): readonly string[];
|
|
106
|
+
/**
|
|
107
|
+
* One level into the fields that are themselves objects — `{ query: ['by', 'value', ...] }`.
|
|
108
|
+
*
|
|
109
|
+
* Naming the top-level fields alone leaves `element` unguessable: `query` is an object with its own
|
|
110
|
+
* required shape, and a plain CSS string works in `reticle_snapshot.scope` and `reticle_query.scope`,
|
|
111
|
+
* so assuming it works here is the natural guess. The rejection is the only place that inconsistency
|
|
112
|
+
* can be explained.
|
|
113
|
+
*
|
|
114
|
+
* One level only. Derived from the schema for the same reason `predicateFieldsFor` is: a stale field
|
|
115
|
+
* list is worse than none, because the agent trusts it and retries into the same wall.
|
|
116
|
+
*/
|
|
117
|
+
export declare function predicateNestedFieldsFor(kind: string): Readonly<Record<string, readonly string[]>>;
|
|
@@ -21,6 +21,10 @@ const PREDICATE_ALIASES = {
|
|
|
21
21
|
// most common thing an agent reaches for here.
|
|
22
22
|
[PredicateKind.ROUTE]: { path: 'pathname', urlContains: 'contains', url: 'contains' },
|
|
23
23
|
[PredicateKind.NET]: { url: 'urlContains' },
|
|
24
|
+
// `textContains` on a `console` predicate: the reporter who hit the missing matcher reached for
|
|
25
|
+
// it first, by analogy with `net { urlContains }` — and that parallel is exactly right, since
|
|
26
|
+
// both mean "this entry must contain this substring". Accepted as an alias for `contains`.
|
|
27
|
+
[PredicateKind.CONSOLE]: { textContains: 'contains' },
|
|
24
28
|
[PredicateKind.SIGNAL]: { data: 'dataMatches' },
|
|
25
29
|
// `of` on a composite: it reads naturally, several assertion libraries spell it that way, and it has
|
|
26
30
|
// no other meaning here. Observed twice in one drive on a real app, and each rejection cost a round
|
|
@@ -246,6 +250,7 @@ function predicateUnion() {
|
|
|
246
250
|
.object({
|
|
247
251
|
kind: z.literal(PredicateKind.CONSOLE),
|
|
248
252
|
level: z.string().optional(),
|
|
253
|
+
contains: z.string().min(1).optional(),
|
|
249
254
|
absent: z.boolean().optional(),
|
|
250
255
|
since: z.number().optional(),
|
|
251
256
|
})
|
|
@@ -307,3 +312,53 @@ export function predicateFieldsFor(kind) {
|
|
|
307
312
|
}
|
|
308
313
|
return [];
|
|
309
314
|
}
|
|
315
|
+
/** Peel optional/nullable/default/effects wrappers off a field to reach the schema underneath. */
|
|
316
|
+
function unwrapSchema(schema) {
|
|
317
|
+
let current = schema;
|
|
318
|
+
for (;;) {
|
|
319
|
+
if (current instanceof z.ZodOptional ||
|
|
320
|
+
current instanceof z.ZodNullable ||
|
|
321
|
+
current instanceof z.ZodDefault) {
|
|
322
|
+
current = current._def.innerType;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (current instanceof z.ZodEffects) {
|
|
326
|
+
current = current._def.schema;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
return current;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* One level into the fields that are themselves objects — `{ query: ['by', 'value', ...] }`.
|
|
334
|
+
*
|
|
335
|
+
* Naming the top-level fields alone leaves `element` unguessable: `query` is an object with its own
|
|
336
|
+
* required shape, and a plain CSS string works in `reticle_snapshot.scope` and `reticle_query.scope`,
|
|
337
|
+
* so assuming it works here is the natural guess. The rejection is the only place that inconsistency
|
|
338
|
+
* can be explained.
|
|
339
|
+
*
|
|
340
|
+
* One level only. Derived from the schema for the same reason `predicateFieldsFor` is: a stale field
|
|
341
|
+
* list is worse than none, because the agent trusts it and retries into the same wall.
|
|
342
|
+
*/
|
|
343
|
+
export function predicateNestedFieldsFor(kind) {
|
|
344
|
+
for (const option of predicateUnion().options) {
|
|
345
|
+
const literal = option.shape['kind'];
|
|
346
|
+
if (!(literal instanceof z.ZodLiteral) || literal.value !== kind)
|
|
347
|
+
continue;
|
|
348
|
+
const nested = {};
|
|
349
|
+
for (const [field, schema] of Object.entries(option.shape)) {
|
|
350
|
+
if ('kind' === field)
|
|
351
|
+
continue;
|
|
352
|
+
const inner = unwrapSchema(schema);
|
|
353
|
+
// `instanceof z.ZodObject` narrows to ZodObject<any>, whose `.shape` is `any`. Name the shape
|
|
354
|
+
// type so the keys are read off something typed rather than laundering an `any` through
|
|
355
|
+
// Object.keys.
|
|
356
|
+
if (inner instanceof z.ZodObject) {
|
|
357
|
+
const shape = inner.shape;
|
|
358
|
+
nested[field] = Object.keys(shape);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return nested;
|
|
362
|
+
}
|
|
363
|
+
return {};
|
|
364
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
export declare function findStaleResponses(events: readonly ReticleEvent[]): Contradiction[];
|
|
2
|
+
import type { OwnContradiction } from './contradictions.js';
|
|
3
|
+
export declare function findStaleResponses(events: readonly ReticleEvent[]): OwnContradiction[];
|