feat(environment): compare an environment with probe captures of its application

Add environment-compare against probe captures of the served application,
keeping the rebased write effects and main's state-file ownership.

Squashed rebase of #5699 onto the rebased #5600 tip.
This commit is contained in:
Cursor Agent committed 2026-10-21 21:28:27 +00:00
1 parent ad8f674d22
commit 12d2bc3cb2
21 files changed
+2881 -27

No files matched your search

+62
View File
@@ -696,6 +696,68 @@ state is unavailable, and that neither is emulation and served
content is untrusted data. It states that the recorded content belongs to the
captured application and must not be published without the rights to it.
## Measure fidelity against the application
An environment agrees with its own capture by construction. What matters to an agent is how it answers the calls the
capture did not make, what it shows after a write, and how it fails. `rea environment-compare` measures that against the
application itself:
```sh
rea environment-compare manifest.json --evidence capture.json \
--probe probe-crud.json --probe probe-reads.json --json
```
Each `--probe` is capture Evidence (`inspect-web-network-capture` of a HAR, or `capture-browser-scenario`) of one call
sequence made against the live application, seeded as it was for the capture and reset before the sequence. The command
makes no network call: whoever records the probes drives the application with its own credentials. For each probe it
resets the environment's state, takes the requests in the order they were made (record order when any lacks a start time), matches each to the manifest tool of the
same method whose path template fits with the most literal segments (a format-rule parameter must fit its format once
percent-decoded),
answers it as the served environment would, and compares that answer with the application's.
A re-seeded application holds the same entities under other ids, so the comparison maps them. Objects in the two answers
to one call pair by a natural key, a string field unique on each side that both share, such as a name or a slug; their
identifier fields (`id`, `uuid`, `guid`, and names ending in `_id`, `Id`, `ID`, `_uuid` or `Uuid`), and those of two single
answers such as a create's, map the live value to the environment's. Ids are learned only from an answer the environment
gave that call itself, from its state or a recording of that very call, request line and body, never from another call's
recording replayed in its place, and the answer's own `id`, `uuid` or `guid` also maps within the collection the call names, since
two collections can reuse a live id. A live id in a later request's path, query or body is translated before the
environment answers, a path parameter by the collection it names, except a query value that pages a list, such as `limit`
or `page`. Each call is checked against the served tool's input schema first, as the MCP server checks it. After mapping, two clock values compare by format only (ISO-8601 strings, and epoch numbers under
time-named fields), and two different values each side generated on its own compare equal: both UUIDs, or one length of
eight or more with the same character classes and a digit. The report counts each kind of normalised value.
A step agrees when the statuses and the normalised bodies are equal. The report gives agreement overall, per probe and
per tool, `agreement_ignoring_order` for steps that differ only in item order, and each step's differences, at most 20,
with a JSON pointer and both values cut short:
| Category | Meaning |
| --------------- | ------------------------------------------------------------------------------------------------------------ |
| `status` | The statuses differ |
| `missing-field` | The application's object has a member the environment's lacks |
| `extra-field` | The environment's object has a member the application's lacks |
| `value` | Values differ, or one is an object or array and the other is not |
| `length` | Arrays differ in length, or not every item pairs |
| `order` | Paired items come in a different order |
| `clock-format` | Both are clocks, in different formats |
| `refused` | The environment refused the call |
| `not-served` | The request's tool is one the environment does not offer, such as a withheld write |
| `no-tool` | No manifest tool matches the request |
| `unmapped-id` | The application answered with success and the environment did not, for a path id the comparison never mapped |
What a probe did not observe is not counted as the application's behaviour. A record without a usable request line,
such as a redacted URL or a transaction the capture cannot attribute, is not replayed, nor is a request to another
origin than the application's (`other-origin`: the probe's origin with the most JSON-like records, as the manifest
chooses its own), and a request with no recorded
answer is made, since it may have changed the application, but not compared: each probe lists them under `unobserved` with the reason, and the summary counts them. A live
body the probe did not record compares by status only and is counted as `unobserved_bodies`, unless the answer never
carries one (204, 205, 304, `HEAD`). A browser probe captured without `capture.network.response_body` is refused, as
is one that makes POST, PUT or PATCH calls without `capture.network.request_body`.
Agreement covers only the calls the probes make, after the mapping the answers allow. Values compared by form, clocks
and generated values, can still differ in content, and an equal answer after a write shows the state matched for that
read, not that the write had no other effect.
## Compatibility verification
`npm run verify:agentenv:environment` is the hosted `real-agentenv` lane
+16
View File
@@ -456,6 +456,22 @@ of the serve command's stopped document. See
[agent environments](agent-environments.md#export-a-bundle) for the folder
layout and what the smoke task checks.
## Compare an environment with its application
`rea environment-compare <manifest.json> --evidence <capture.json> --probe <probe.json> [--probe <probe.json> ...]`
compares the environment the pair serves with probe captures of the application it came from. Each probe is capture
Evidence of one call sequence recorded against the application after a reset; the environment's state is reset before
each probe, every request to the application's origin is answered as the served environment answers it, and the
answer is compared with the application's after ids are mapped and clocks and generated values are normalised. The command reads files only and
makes no network call. It prints the report and exits `0`, or prints a structured `invalid_request` error and exits `1`
for the same pair refusals as `environment-serve`, a probe that is not capture Evidence, or a browser probe captured
without response bodies, or without the request bodies its writes need. See
[agent environments](agent-environments.md#measure-fidelity-against-the-application) for the report.
```sh
rea environment-compare manifest.json --evidence capture.json --probe probe.json --json
```
## Output and exit status
`rea mcp doctor --json` checks production MCP startup and reports child logs in
@@ -0,0 +1,12 @@
# Plan
1. Export the capture parser from `environmentSource.ts` so a probe loads like a capture.
2. `environmentCompareMatch.ts`: a probe request to a tool and its arguments.
3. `environmentCompareIds.ts`: the id map, natural-key pairing, learning from two answers, translating arguments.
4. `environmentCompareDiff.ts`: normalisation and categorised differences.
5. `environmentCompare.ts`: per probe, reset the state, answer each request, compare and learn; the report.
6. `rea environment-compare` in `src/cli/environmentCommands.ts`, documented in `docs/agent-environments.md` and
`docs/cli.md`.
7. Tests: matching, pairing, normalisation and categories as unit tests; the command over a fixture capture and probes.
8. Verification on VMs: six applications brought up and seeded as for their capture, reset from a snapshot per sequence,
driven by a script that records each sequence as a HAR; baseline reports per environment.
@@ -0,0 +1,75 @@
# Compare an environment with the application it came from
Issue: https://github.com/morluto/rea/issues/4933
## Outcome
An environment is only as useful as it is faithful: an agent trained or graded on it should meet the behaviour of the
application it was captured from. REA checks an environment only against its own capture, so nothing says how far it
drifts from the application on calls the capture did not make, after writes, or on errors.
`rea environment-compare` measures that. It takes an environment (a manifest and its capture Evidence) and one or more
**probe captures**, each a recording of one call sequence made against the live application, and reports how many of the
probe's calls the environment answers as the application did, which tools disagree and how.
The command only reads Evidence: it makes no network call. Whoever records the probes drives the application with its own
credentials and resets it between sequences.
## Requirements
- Inputs: the manifest Evidence, its capture Evidence (`--evidence`), and one or more probe capture Evidence files
(`--probe`, repeatable), each `inspect-web-network-capture` or `capture-browser-scenario` Evidence of one sequence.
- Each probe is one sequence run against a freshly reset application. Before each probe the environment's state is reset,
so sequences are independent on both sides.
- Requests are taken in the probe's order (start time, then ordinal; ordinal alone when any record lacks a start
time, as state extraction orders them). A request is matched to the manifest tool of the
same method whose path template it fits with the most literal segments; format-rule parameters must fit their format
once percent-decoded, as replay reads them.
Path values, query values the tool takes and a JSON body become the tool's arguments.
- A record without a usable request line (a redacted URL, a transaction the capture cannot attribute, an unparseable
URL or no method) is not replayed, nor is a request to another origin than the probe's application (`other-origin`),
which is the origin with the most JSON-like records, as the manifest chooses its own. A request with no recorded
answer is made, since it may have changed the application, but not compared. Each is listed per probe with its
reason and left out of agreement.
- A request no tool matches is reported `no-tool`; a matched tool the environment does not offer, such as a write it
withholds, is reported `not-served`. Neither is answered.
- Ids differ between a re-seeded application and the capture. The comparison maps them:
- seeded entities pair across the two answers by a natural key: a field present on every element of both arrays whose
string values are unique on each side and shared between them;
- identifier fields (`id`, `uuid`, `guid`, and names ending in `_id`, `Id`, `ID`, `_uuid` or `Uuid`) of paired objects,
and of the two answers to the same call, map the live value to the environment's value; a create's two answers map
the new ids. The `id`, `uuid` or `guid` of the answer's own entities (at the read view's entity or list location, a
write's response location, or else the root) also maps within the collection the call names,
since two collections can reuse a live id for different entities. Outside identifier fields, a string maps when the
environment's state holds it as an id, and the keys of two objects keyed by ids map when their values pair (keys
that partly overlap only when they are digits, UUIDs or their entries' identities, and every value pairs);
- ids are learned only from an answer the environment gave this call, from its state or from a recording of exactly
this call, its whole request line and its request body: a recording of another call that replay answers with
instead holds other entities;
- path, query and body values equal to a mapped live id are translated before the environment answers, a path
parameter by the collection it names, except query values that page a list (`limit`, `offset`, `page` and the
like), which count items rather than name one.
A path value the map does not know, and the environment's state does not hold, is reported `unmapped-id`.
- A call is checked against the served tool's input schema first, as the MCP server checks it; a call it refuses is
reported `refused`. A browser probe keeps its request bodies; one captured without response bodies is refused, since
only statuses would compare, and so is one that makes POST, PUT or PATCH calls without recorded request bodies, since
those calls would be made empty.
- Answers compare after normalisation: empty response text equals no body (a JSON empty string stays one); mapped ids
compare equal, in scalar sets as well, an entity's own identity within the call's collection and any other id by the
probe's mapping; two clock values (ISO-8601 strings, or epoch numbers under
time-named fields) compare by format only; two different generated values (both UUIDs, or both of one length and the
same character classes with a digit) that neither side maps compare equal and are counted. A body over the replay
limit, text or binary, compares by status only, as does a live body the probe did not record, unless the answer has
none by definition (204, 205, 304, or an answer to `HEAD`). Both are counted.
- A step agrees when the statuses are equal and the normalised bodies are. Differences are categorised: `status`,
`missing-field` (the application had it), `extra-field`, `value`, `length`, `order`, `clock-format`, `refused` (the
environment refused the call), `not-served`, `no-tool` and `unmapped-id`, each with a JSON pointer and short values.
- The report gives agreement overall, per probe and per tool, agreement ignoring item order beside it, the counts per
category, the ids mapped and the values normalised, the records left uncompared, and each step's differences (at most
20 per step).
## Non-goals
- No network access, no credentials, no reset of the application: probes are recorded outside REA.
- No generation of call sequences; a later change generates them where the environment disagrees.
- No change to how environments serve.
@@ -0,0 +1,12 @@
# Tasks
- [x] Record the outcome, requirements and non-goals.
- [x] Load probes as captures.
- [x] Match probe requests to tools.
- [x] Map ids and translate arguments.
- [x] Normalise and categorise differences.
- [x] Compare per probe and report.
- [x] Add the command and its documentation.
- [x] Add unit and command tests.
- [x] Run the gates.
- [x] Measure six environments against their live applications and record the results.
@@ -0,0 +1,146 @@
# Verification
Verified commit: 235b22f2 on the write-plan branch at 7056f5ff. Executed on macOS with Node v22.23.1 (the repository
pins 24.18.0 in `.nvmrc`, which was not installed); the live applications ran on six disposable Linux VMs.
## Gates
- `npm run build:cached`, `npm run check:fast` (typecheck and lint), `npm run format:check`, `npx knip`,
`npm run scan:todos`, `npm run docs:check`, `npm run verify:test-discovery` and `npm run mcp-catalog:check`: passed.
- `npm run jscpd`: 3.46% duplicated lines over 3609 files, under the 3.5% gate.
- `npx vitest run --project domain --project services`: 7487 passed, 5 pre-existing skips.
- `npm run test:prepare`, then `npm run test:focused --` the environment-compare, export and serve CLI boundaries, the
serve MCP boundary and the tool schema validity boundary: 5 files, 30 passed.
New tests: matching, id mapping and translation, pairing guards, normalisation and every category as unit tests
(`environmentCompareIds`, `environmentCompareDiff`, `environmentCompare`, 19 tests), and the command through the built
CLI (`environmentCompareCli`, 2 tests): a probe of the books application re-seeded under other ids agrees on every step
with 2 ids mapped, and a probe that is not capture Evidence is refused with its index.
## Six environments against their live applications
Six published environments were compared with the application each was captured from: NetBox 4.7.3, Komga 1.29.1,
Mealie 3.28.0, n8n 1.123.85, Shlink 5.1.7 and self-hosted Sentry 26.9.0, the same images, brought up and seeded by the
same scripts as for their captures, on loopback. Each was snapshotted after seeding and restored from that snapshot
before every sequence (7 to 80 s), so every sequence met the same application.
A driver outside REA ran three kinds of sequence per application, each starting with the capture's read-only prefix:
the capture re-run in order; per served write type, a list, create, read, update, read, delete, read and list, then a
second create with changed free text; and a read of every seeded entity through its item tool. It translated the
environment's ids to the application's as it learned them, signed in with the application's own credentials, and wrote
each sequence as a HAR without headers or cookies, which `inspect-web-network-capture` turned into probe Evidence. The
environments' manifests use `prefix-variance`, so the comparison ran on a local build of this branch with that rule's
branch merged.
| Environment | Steps | Agreement | Ignoring order | Against its own capture |
| ----------- | ----: | --------: | -------------: | ----------------------: |
| NetBox | 370 | 99.2% | 99.2% | 100% |
| Komga | 356 | 93.8% | 93.8% | 93.1% |
| Mealie | 301 | 96.0% | 97.0% | 97.1% |
| Shlink | 133 | 89.5% | 92.5% | 92.4% |
| n8n | 202 | 74.8% | 87.6% | 87.5% |
| Sentry | 230 | 23.9% | 46.5% | 84.7% |
The last column compares each environment with its own capture as the probe: what it cannot reproduce even of the
session it was built from. The disagreements fall into four groups.
- Environment gaps, the ones to fix:
- write tools the environment withholds, and the reads after them: readlists and collections (Komga), recipes (Mealie), short URLs
(Shlink), workflow and tag writes (n8n);
- preconditions: n8n refuses to delete a workflow that is not archived and the environment deletes it;
- lists: a create does not appear in a list that keeps its recorded members (Komga), a filtered list is answered
without the filter (Shlink), a replayed search ignores its body (Komga);
- a domain-scoped short URL answered without its `domain` parameter, where Shlink answers 404;
- state rendering: a project field two recorded views disagree on is rendered with one value, and an organisation
read whose shape depends on a query parameter is answered the same either way (Sentry);
- a tag read refused for want of the fields its item view always carried (Mealie);
- time: per-hour event counts move with the current time, while the environment's clock is frozen at the capture
(Sentry);
- a field the capture's scrubbing replaced, answered as `"<scrubbed>"` (Sentry).
- The application differing from itself between seedings: Sentry returns permission lists in no fixed order and
reseeding changed release first-event times, feature lists and profile flags; n8n's executions and NetBox's running
worker count differ from the captured instance.
- The probe observing itself: Komga and Sentry report the driver's own sign-in user agent.
- Driver gaps: ids the driver never mapped (Mealie units, Sentry events by hash) were sent as the environment's ids.
## What the runs fixed in the comparison
Each live run found a way the comparison itself was wrong, fixed with a test before the next run: requests are matched
to tools in the environment's terms, so a tool whose path holds a seeded id literally matches; ids are learned outside
identifier fields only from strings, after NetBox's worker count of 1 mapped a live 0 to entity 1; items paired only by
position teach ids only when no key is shared and the lengths agree, after a filtered Shlink list mapped one short URL to
another; objects keyed by ids pair their entries (n8n data tables); reordered scalar sets differ in order only and epoch
numbers in arrays are clocks (Sentry); and bodies over the replay limit compare by status.
## After the review round
The branch merged the write-plan branch at b6c3b168 (four serving and gate fixes its review found), then changed the
comparison in nine places: ids are learned only from answers to the call itself and also per collection, paging values
are never translated, calls go through the served tools' input schemas, browser probes keep their request bodies, empty
bodies and large binary bodies compare as the server answers them, scalar sets compare after mapping, and id-keyed
objects pair when some keys overlap. Each new test fails on the previous head. The gates above passed again (domain and
services 7504 passed with 5 pre-existing skips; the focused boundaries 80; conformance; `jscpd` 3.46%). Re-run on the
same probes with this branch and the template rule's merged, the six environments compare exactly as in the table above,
step for step, with the same ids mapped and the same values normalised.
A second round, on Greptile's review of those changes, learned ids from a replay only when the whole recorded line and
request body match the call, compared an entity's own identity within the call's collection and other ids by the probe's
mapping, read overlapping keys as ids only when they evidently are ids, and kept a JSON empty string apart from empty
text. Its four tests fail on the previous head; the six environments again compare exactly as in the table.
A third round located the answer's own entities where the served environment does, at the read view's entity or list
pointer or the write's response pointer, so an entity inside an envelope keeps its collection's mapping. Its test fails
on the previous head; the gates passed again (domain and services 7509, boundaries 80, conformance, `jscpd` 3.46%) and
the six environments again compare exactly as in the table.
A fourth round, on Greptile's next review, kept what a probe did not observe out of the agreement figures. Records
without a usable request line, and requests without a recorded answer, are listed per probe with the reason and left
uncompared. A live body the probe did not record compares by status only and is counted. A browser probe captured
without response bodies is refused. A format-rule path parameter fits by its decoded value, as replay reads it. The
branch also merged the write-plan branch at 1d1c4787, whose gate no longer confirms a write by a read of a state that a
later write replaced. Each new test fails on the previous head. The gates passed again (domain and services
7647, 5 pre-existing skips; the compare, export, serve and analysis-view boundaries 69; conformance 279; `jscpd` 3.44%).
On the same probes, the six environments have no unobserved records or bodies, and five compare exactly as before.
Komga now withholds its collection create and patch. The only read the gate could check them by is the 404 after the
collection is deleted, because the read after the patch is the patch's own observation. Its agreement falls from
96.9% to 93.8%: the three creates and two patches the probes make are not served, and the six reads and deletes of
those collections that follow fail. Against its own capture it falls from 96.6% to 93.1%. The table shows the new
figures.
A fifth round merged the write-plan branch at dc5e2aad, which weighs an update's derived changes when a later write
replaces it and resets collection spellings on reset or load. It also makes a request whose answer the probe lost,
still uncompared, so that a later read sees its effect: a lost delete followed by a 404 now agrees. Its test fails on
the previous head. The six environments compare as in the table.
A sixth round merged the write-plan branch at fe8ab584, whose gate erases the fields later writes and recorded
outcomes overwrite from earlier write effects. The six environments again compare exactly as in the table.
A seventh round merged the write-plan branch at 3580d85c and main d8a9673f. That brought the gate's fix for overwritten
document writes, which Greptile raised here against the write-plan code, among others. It also fixed two findings of
its own:
- a probe with any undated record keeps its recorded order, as state extraction does;
- a browser probe that makes POST, PUT or PATCH calls without recorded request bodies is refused.
Both tests fail on the previous head. The six environments compare exactly as in the table, on the probes and against
their own captures.
An eighth round merged the write-plan branch at 43d98ea9 and 264ce410. They bring the gate's fixes for three findings
Greptile raised here against the write-plan code: a passing read that shows nothing of a write leaves it pending, an
update or delete finds its entity as an item read does, and a built document plan writes later calls at its own
object. It also fixed one finding of its own: a request to another origin than the probe's application is neither
made nor compared, so another server's `DELETE /api/books/2` no longer deletes the environment's book. Its test fails
on the previous head. The six environments compare exactly as in the table, on the probes and against their own
captures, with no unobserved records.
It then merged the write-plan branch at 0ae64400 and 6257de93: an update's identity member never takes a rule, a page
size of zero bounds nothing, and the paging rules, with this branch's `isPagingParameter`, move to
`environmentStatePaging.ts`. The six environments again compare exactly as in the table.
A ninth round merged the write-plan branch at 466ed8b4, which fixes two findings Greptile raised here against its
code (entity fields that look like pointers match by name; a zero page size bounds nothing only where a recording
listed items under it) among others. The six environments compare exactly as in the table, on the probes and against
their own captures. It then merged 7cef2fb2, whose fixes settle three more findings Greptile raised here against that branch's code:
zero page sizes erasing another bound, partial write responses taken for removals, and interference timed by a tool's
call elsewhere. The six environments again compare exactly as in the table.
@@ -0,0 +1,445 @@
import { describe, expect, it } from "vitest";
import type { JsonValue } from "../../domain/jsonValue.js";
import {
analysisViewCaptureEvidence,
environmentManifestCaptureFixture,
timedCaptureEvidence,
type TimedCaptureRow,
} from "../../../tests/fixtures/analysisView.js";
import {
environmentServeSource,
environmentStateServeFixture,
} from "../../../tests/fixtures/environmentServe.js";
import {
BOOK_NOT_FOUND,
BOOK_ROWS,
FIRST_BOOK,
SECOND_BOOK,
book,
renamedBook,
} from "../../../tests/fixtures/environmentStateWrites.js";
import {
scenarioCaptureEvidence,
scenarioCaptureFixture,
scenarioHeaderOnlyCaptureEvidence,
} from "../../../tests/fixtures/browserScenarioCapture.js";
import { compareEnvironment } from "./environmentCompare.js";
import { environmentArguments, IdMap } from "./environmentCompareIds.js";
import { matchProbeRequest } from "./environmentCompareMatch.js";
import { isCut } from "./environmentCompareProbe.js";
import { parseCaptureEvidence } from "./environmentSource.js";
const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS));
const source = environmentServeSource(fixture);
/** The books application re-seeded: the same books under other ids and creation times. */
const LIVE_FIRST = book(101, "First Book", "2026-10-20T09:00:00Z");
const LIVE_SECOND = book(102, "Second Book", "2026-10-20T10:00:00Z");
const LIVE_FOURTH = book(103, "Fourth Book", "2026-10-20T11:00:00Z");
const LIVE_RENAMED = renamedBook(LIVE_FIRST, "Renamed Book", 5);
const ANN = { id: 1, name: "Ann" };
const BOB = { id: 2, name: "Bob" };
const compare = (...probes: (readonly TimedCaptureRow[])[]) => {
const report = compareEnvironment({
source,
captureValue: fixture.capture,
probes: probes.map((rows, index) => ({
label: `probe-${String(index + 1)}`,
value: timedCaptureEvidence(rows),
})),
operation: "environment-compare",
});
if (!report.ok) throw report.error;
return report.value;
};
describe("matchProbeRequest", () => {
it("prefers literal segments, requires format-rule parameters to fit, and maps query and body arguments", () => {
const item = matchProbeRequest(source.manifest, {
method: "GET",
path: "/api/books/7",
query: "?expand=all",
body: undefined,
});
expect(item?.tool.path_template).toBe("/api/books/{numeric}");
expect(item?.args).toEqual({ id: "7" });
expect(item?.ignoredQuery).toEqual(["expand"]);
const create = matchProbeRequest(source.manifest, {
method: "POST",
path: "/api/books",
query: null,
body: { name: "X" },
});
expect(create?.args).toEqual({ body: { name: "X" } });
expect(
matchProbeRequest(source.manifest, {
method: "GET",
path: "/api/books/x",
query: null,
body: undefined,
}),
).toBeUndefined();
});
it("fits a format-rule parameter by its decoded value, as replay matches it", () => {
const item = matchProbeRequest(source.manifest, {
method: "GET",
path: "/api/books/%31",
query: null,
body: undefined,
});
expect(item?.tool.path_template).toBe("/api/books/{numeric}");
expect(item?.args).toEqual({ id: "1" });
});
});
describe("compareEnvironment", () => {
it("agrees with a re-seeded application once seeded and created ids are mapped", () => {
const report = compare([
["GET", "/api/books", [LIVE_FIRST, LIVE_SECOND]],
["GET", "/api/books/101", LIVE_FIRST],
["POST", "/api/books", LIVE_FOURTH, 201, { name: "Fourth Book" }],
["GET", "/api/books/103", LIVE_FOURTH],
["PATCH", "/api/books/101", LIVE_RENAMED, 200, { name: "Renamed Book" }],
["GET", "/api/books/101", LIVE_RENAMED],
["DELETE", "/api/books/103", "", 204],
["GET", "/api/books/103", BOOK_NOT_FOUND, 404],
["GET", "/api/books", [LIVE_RENAMED, LIVE_SECOND]],
]);
expect(report.environment.state).toBe("served");
expect(report.probes[0]?.results.filter((step) => !step.equal)).toEqual([]);
expect(report.summary).toMatchObject({
probes: 1,
steps: 9,
equal: 9,
agreement: 1,
});
expect(report.probes[0]?.ids_mapped).toBe(3);
expect(report.summary.normalised.ids).toBeGreaterThan(0);
});
it("resets the state per probe and categorises every kind of disagreement", () => {
const report = compare(
[
["GET", "/api/books", [LIVE_FIRST, LIVE_SECOND]],
["DELETE", "/api/books/101", { detail: "Book is referenced." }, 409],
["GET", "/api/authors", []],
["GET", "/api/books/555", LIVE_FIRST],
],
[["GET", "/api/books", [LIVE_SECOND, LIVE_FIRST, LIVE_FOURTH]]],
);
const [first, second] = report.probes;
expect(first?.results.map((step) => step.categories)).toEqual([
[],
["status", "value"],
["no-tool"],
["unmapped-id", "status", "missing-field", "extra-field"],
]);
expect(second?.results[0]?.categories).toEqual(["length", "order"]);
expect(second?.results[0]?.equal_ignoring_order).toBe(false);
expect(report.summary.agreement_ignoring_order).toBe(
report.summary.agreement,
);
expect(report.summary.categories).toMatchObject({
status: 2,
"no-tool": 1,
"unmapped-id": 1,
length: 1,
order: 1,
});
expect(report.tools.map((tool) => tool.tool)).toContain(
"delete_books_by_id",
);
});
});
describe("what the comparison translates and learns", () => {
/** The books capture with a paged, filtered list and reports the state does not model, so other reports replay this one. */
const extra = environmentStateServeFixture(
timedCaptureEvidence([
...BOOK_ROWS,
["GET", "/api/books?limit=2&author=7", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/reports/1", [{ owner_id: 7 }]],
["GET", "/api/authors", [ANN, BOB]],
["GET", "/api/authors/1", ANN],
["GET", "/api/authors/2", BOB],
["GET", "/api/ping", ""],
["POST", "/api/books/search", [FIRST_BOOK], 200, { q: "First" }],
["POST", "/api/tags/search", [{ id: 1 }], 200, { q: "first" }],
["GET", "/api/motd", '""'],
]),
);
const extraSource = environmentServeSource(extra);
const compareExtra = (rows: readonly TimedCaptureRow[]) => {
const report = compareEnvironment({
source: extraSource,
captureValue: extra.capture,
probes: [{ label: "probe", value: timedCaptureEvidence(rows) }],
operation: "environment-compare",
});
if (!report.ok) throw report.error;
return report.value.probes[0]!;
};
it("translates a path id by the collection it names when two collections reuse a live id", () => {
// Live book 101 is the environment's book 1, while live author 101 is its author 2.
const probe = compareExtra([
["GET", "/api/books", [LIVE_FIRST, LIVE_SECOND]],
[
"GET",
"/api/authors",
[
{ ...ANN, id: 102 },
{ ...BOB, id: 101 },
],
],
["GET", "/api/authors/101", { ...BOB, id: 101 }],
]);
expect(probe.results.map((step) => step.equal)).toEqual([true, true, true]);
});
it("refuses arguments the served tool's input schema refuses, as the server would", () => {
// A search replays its recording whatever it is sent, but the server refuses a body that is not an object.
const [step] = compareExtra([
["POST", "/api/books/search", [FIRST_BOOK], 200, ["First"]],
]).results;
expect(step?.categories).toEqual(["refused"]);
expect(step?.differences[0]?.env).toContain("Invalid arguments");
});
it("agrees on an empty response however each side spells it, and keeps a JSON empty string", () => {
expect(compareExtra([["GET", "/api/ping", ""]]).results[0]?.equal).toBe(
true,
);
expect(compareExtra([["GET", "/api/motd", '""']]).results[0]?.equal).toBe(
true,
);
});
it("learns ids from a replayed recording only of the same call, body included", () => {
expect(
compareExtra([
["POST", "/api/tags/search", [{ id: 102 }], 200, { q: "second" }],
]).ids_mapped,
).toBe(0);
expect(
compareExtra([
["POST", "/api/tags/search", [{ id: 102 }], 200, { q: "first" }],
]).ids_mapped,
).toBe(1);
});
it("compares a large binary body only by status, as it does large text", () => {
expect(isCut({ kind: "base64", encoded: "AAAA", whole: false })).toBe(true);
expect(isCut({ kind: "base64", encoded: "AAAA", whole: true })).toBe(false);
expect(isCut({ kind: "cut", text: "a" })).toBe(true);
});
it("translates mapped ids in query values except those that page a list", () => {
const call = matchProbeRequest(extraSource.manifest, {
method: "GET",
path: "/api/books",
query: "?limit=101&author=101",
body: undefined,
});
const map = new IdMap();
map.learn(101, 1);
expect(call?.pagingArguments).toEqual(["limit"]);
expect(call && environmentArguments(call, map)).toEqual({
limit: "101",
author: "1",
});
});
it("learns no ids from another call's recording replayed in place of an answer", () => {
const report = compareEnvironment({
source: extraSource,
captureValue: extra.capture,
probes: [
{
label: "reports",
value: timedCaptureEvidence([
["GET", "/api/reports/2", [{ owner_id: 107 }]],
]),
},
],
operation: "environment-compare",
});
if (!report.ok) throw report.error;
const [probe] = report.value.probes;
expect(probe?.ids_mapped).toBe(0);
expect(probe?.results[0]?.differences).toEqual([
{ category: "value", pointer: "/0/owner_id", live: "107", env: "7" },
]);
});
});
describe("browser probes", () => {
it("keeps a browser probe's request bodies, which are its call arguments", () => {
const post = (purpose?: "manifest") => {
const parsed = parseCaptureEvidence(
"environment-compare",
scenarioCaptureEvidence(),
purpose,
);
if (!parsed.ok) throw parsed.error;
return parsed.value.records.find((record) =>
JSON.stringify(record.reported).includes('"method":"POST"'),
)?.reported;
};
expect(JSON.stringify(post("manifest"))).toContain("postData");
expect(JSON.stringify(post())).not.toContain("postData");
});
});
describe("what a probe did not observe", () => {
const at = (second: number): string =>
new Date(
Date.parse("2026-10-20T09:00:00.000Z") + second * 1000,
).toISOString();
const exchange = (
second: number,
method: string,
url: string | null,
status: number,
) => ({
startedDateTime: at(second),
request: { method, url },
response: { status, content: { mimeType: "application/json" } },
});
const run = (value: unknown) =>
compareEnvironment({
source,
captureValue: fixture.capture,
probes: [{ label: "probe-1", value }],
operation: "environment-compare",
});
it("compares an unrecorded body by status only and lists the records it could not compare", () => {
const report = run(
analysisViewCaptureEvidence(
environmentManifestCaptureFixture([
exchange(0, "GET", "http://app.example/api/books/1", 200),
exchange(1, "GET", null, 200),
{ startedDateTime: at(2) },
exchange(3, "GET", "http://app.example/api/books", 0),
// A 204 carries no body, so its missing body is known to be empty.
exchange(4, "DELETE", "http://app.example/api/books/2", 204),
]),
),
);
if (!report.ok) throw report.error;
expect(report.value.probes[0]?.unobserved).toEqual([
{ ordinal: 1, reason: "url-redacted" },
{ ordinal: 2, reason: "request-unavailable" },
{ ordinal: 3, reason: "no-response" },
]);
expect(report.value.summary).toMatchObject({
steps: 2,
equal: 2,
unobserved: 3,
});
expect(report.value.summary.normalised.unobserved_bodies).toBe(1);
});
it("makes a request whose answer was lost, so later reads see its effect", () => {
const report = run(
analysisViewCaptureEvidence(
environmentManifestCaptureFixture([
exchange(0, "DELETE", "http://app.example/api/books/2", 0),
{
...exchange(1, "GET", "http://app.example/api/books/2", 404),
response: {
status: 404,
content: {
mimeType: "application/json",
text: JSON.stringify(BOOK_NOT_FOUND),
},
},
},
]),
),
);
if (!report.ok) throw report.error;
expect(report.value.probes[0]?.unobserved).toEqual([
{ ordinal: 0, reason: "no-response" },
]);
expect(report.value.summary).toMatchObject({ steps: 1, equal: 1 });
});
it("makes no request to another origin, so it cannot change the environment", () => {
const answered = (second: number, url: string, body: JsonValue) => ({
...exchange(second, "GET", url, 200),
response: {
status: 200,
content: { mimeType: "application/json", text: JSON.stringify(body) },
},
});
const report = run(
analysisViewCaptureEvidence(
environmentManifestCaptureFixture([
// The probe also called another server's API, at a path the application's tools match.
exchange(0, "DELETE", "http://cdn.example/api/books/2", 204),
answered(1, "http://app.example/api/books/1", FIRST_BOOK),
answered(2, "http://app.example/api/books/2", SECOND_BOOK),
]),
),
);
if (!report.ok) throw report.error;
expect(report.value.probes[0]?.unobserved).toEqual([
{ ordinal: 0, reason: "other-origin" },
]);
expect(report.value.summary).toMatchObject({ steps: 2, equal: 2 });
});
it("keeps the recorded order when a record has no start time, as state extraction does", () => {
const { startedDateTime: _undated, ...read } = {
...exchange(1, "GET", "http://app.example/api/books/2", 404),
response: {
status: 404,
content: {
mimeType: "application/json",
text: JSON.stringify(BOOK_NOT_FOUND),
},
},
};
const report = run(
analysisViewCaptureEvidence(
environmentManifestCaptureFixture([
exchange(0, "DELETE", "http://app.example/api/books/2", 204),
read,
]),
),
);
if (!report.ok) throw report.error;
expect(report.value.summary).toMatchObject({ steps: 2, equal: 2 });
});
it("refuses a browser probe that makes writes without recorded request bodies", () => {
const report = run(
scenarioCaptureEvidence(
scenarioCaptureFixture(undefined, {
request_body: false,
response_body: true,
header_values: true,
}),
),
);
expect(report.ok).toBe(false);
if (report.ok) return;
expect(JSON.stringify(report.error)).toContain(
"needs capture.network.request_body selected",
);
});
it("refuses a browser probe captured without response bodies", () => {
const report = run(scenarioHeaderOnlyCaptureEvidence());
expect(report.ok).toBe(false);
if (report.ok) return;
expect(JSON.stringify(report.error)).toContain(
"probe-1: a browser probe needs capture.network.response_body selected",
);
});
});
@@ -0,0 +1,487 @@
import { fromJsonSchema } from "@modelcontextprotocol/server";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
compareAnswers,
DIFFERENCE_CATEGORIES,
STEP_DIFFERENCE_LIMIT,
type Answer,
type Difference,
type DifferenceCategory,
type Normalised,
} from "./environmentCompareDiff.js";
import {
environmentArguments,
IdMap,
learnIds,
translatePath,
} from "./environmentCompareIds.js";
import {
collectionScope,
matchProbeRequest,
parameterScopes,
type ProbeToolCall,
} from "./environmentCompareMatch.js";
import {
probeRequests,
requestBody,
sendsBodies,
type ProbeStep,
type UnobservedRecord,
} from "./environmentCompareProbe.js";
import {
inputError,
parseCaptureEvidence,
replayedBody,
type EnvironmentManifestTool,
type EnvironmentSource,
} from "./environmentSource.js";
import {
selectsRecordedLine,
type ReplayArguments,
type ReplayResult,
} from "./environmentReplay.js";
import { prepareServedState, servedCatalog } from "./environmentState.js";
import type { StateAnswer } from "./environmentStateAnswer.js";
import { EnvironmentStateStore } from "./environmentStateStore.js";
import {
environmentToolAnswer,
type EnvironmentToolAnswer,
} from "./environmentToolAnswer.js";
import {
environmentToolArguments,
type EnvironmentToolDefinition,
} from "./environmentTools.js";
import { same } from "../../domain/analysisView/environmentStateRules.js";
/** One probe capture file: its label for the report and its parsed JSON. */
export interface CompareProbeInput {
readonly label: string;
readonly value: unknown;
}
/** One probe request, how the environment answered it, and how that differs from the application. */
export interface CompareStep {
readonly ordinal: number;
readonly method: string;
readonly path: string;
readonly tool: string | null;
readonly live_status: number | null;
readonly env_status: number | null;
readonly equal: boolean;
/** Equal but for item order, which an application that returns sets in no fixed order cannot reproduce either. */
readonly equal_ignoring_order: boolean;
readonly categories: DifferenceCategory[];
readonly differences: Difference[];
readonly ignored_query: string[];
}
export interface CompareProbeReport {
readonly label: string;
readonly evidence_id: string;
readonly steps: number;
readonly equal: number;
readonly ids_mapped: number;
readonly id_conflicts: number;
readonly normalised: Normalised;
readonly results: CompareStep[];
/** Records left uncompared, in probe order, each with its reason. */
readonly unobserved: UnobservedRecord[];
}
export interface CompareReport {
readonly status: "complete";
readonly environment: {
readonly name: string;
readonly manifest_evidence_id: string;
readonly evidence_id: string;
readonly state: "served" | "replay-only";
};
readonly summary: {
readonly probes: number;
readonly steps: number;
readonly equal: number;
readonly agreement: number | null;
readonly agreement_ignoring_order: number | null;
/** Probe records left uncompared, outside the steps agreement counts. */
readonly unobserved: number;
readonly categories: Record<DifferenceCategory, number>;
readonly normalised: Normalised;
};
readonly tools: { tool: string; steps: number; equal: number }[];
readonly probes: CompareProbeReport[];
}
const NORMALISED_KEYS = [
"ids",
"clocks",
"generated",
"large_bodies",
"unobserved_bodies",
] as const;
const emptyNormalised = (): Normalised => ({
ids: 0,
clocks: 0,
generated: 0,
large_bodies: 0,
unobserved_bodies: 0,
});
const isSuccess = (status: number | null): boolean =>
status !== null && status >= 200 && status < 300;
/** Where a call's answer holds its own entities, and the collection they belong to: a read view's entity or list pointer, a write's response pointer, else the root. */
const entityOf = (
run: ProbeRun,
tool: EnvironmentManifestTool,
): { readonly scope: string; readonly at: string } => {
const view = run.store?.state.views.get(tool.name);
const plan = run.store?.state.writes.get(tool.name);
const at =
view?.shape === "item" || view?.shape === "list"
? view.pointer
: (plan?.response?.pointer ?? plan?.observation?.pointer ?? "");
return { scope: collectionScope(tool), at };
};
const fromState = (answer: ReplayResult): boolean =>
(answer as Partial<StateAnswer>).state?.source === "state";
/** The environment's body as the application's is read: a replayed recording of empty text, unlike a JSON empty string, is no body. */
const envBody = (run: ProbeRun, answer: ReplayResult): JsonValue => {
if (answer.body !== "" || fromState(answer)) return answer.body;
const exchange = run.source.exchanges.get(answer.replay.record_ordinal);
return exchange !== undefined && replayedBody(exchange).kind === "json"
? answer.body
: null;
};
/**
* Whether the environment answered this call itself: from its state, or from a recording of exactly this call, every
* recorded query value supplied and the body it sent, rather than replaying another call's recording.
*/
const answersCall = (
run: ProbeRun,
call: ProbeToolCall,
args: Readonly<Record<string, JsonValue>>,
answer: ReplayResult,
): boolean => {
if (fromState(answer)) return true;
const exchange = run.source.exchanges.get(answer.replay.record_ordinal);
if (
!answer.replay.exact_match ||
exchange === undefined ||
!selectsRecordedLine(call.tool, exchange, args)
)
return false;
const body = environmentToolArguments(call.tool).body;
const sent = body === undefined ? undefined : args[body];
return (
sent === undefined || same(run.recordedBodies.get(exchange.ordinal), sent)
);
};
/** A served tool's input check: the first problem with a call's arguments, or undefined. */
type InputCheck = (args: ReplayArguments) => string | undefined;
/** The input schema check the MCP server applies before a served tool answers, so a call it refuses is not compared as answered. */
const inputCheck = (definition: EnvironmentToolDefinition): InputCheck => {
const schema = fromJsonSchema<ReplayArguments>(definition.inputSchema)[
"~standard"
];
return (args) => {
const checked = schema.validate(args);
return checked instanceof Promise
? undefined
: checked.issues?.[0]?.message;
};
};
interface ProbeRun {
readonly source: EnvironmentSource;
/** The request body of each capture record, by ordinal, that a replayed answer's call is compared with. */
readonly recordedBodies: ReadonlyMap<number, JsonValue | undefined>;
readonly answer: EnvironmentToolAnswer;
readonly store: EnvironmentStateStore | undefined;
/** The tools the environment offers, by name, with their input checks. */
readonly served: ReadonlyMap<string, InputCheck>;
readonly map: IdMap;
readonly normalised: Normalised;
}
const heldIds = (
store: EnvironmentStateStore | undefined,
): ReadonlySet<string> =>
new Set(
store?.snapshot().rea_state.entities.map((entity) => entity.id) ?? [],
);
const stepOf = (
base: Pick<CompareStep, "ordinal" | "method" | "path" | "live_status">,
call: ProbeToolCall | undefined,
outcome: { env_status: number | null; differences: Difference[] },
): CompareStep => {
const categories = [...new Set(outcome.differences.map((d) => d.category))];
return {
...base,
tool: call?.tool.name ?? null,
env_status: outcome.env_status,
equal: outcome.differences.length === 0,
equal_ignoring_order: outcome.differences.every(
(difference) => difference.category === "order",
),
categories,
differences: outcome.differences.slice(0, STEP_DIFFERENCE_LIMIT),
ignored_query: [...(call?.ignoredQuery ?? [])],
};
};
/** Answer one probe request as the environment would and compare it with the application's answer; one without an answer is only made. */
const compareStep = (
run: ProbeRun,
manifest: EnvironmentSource["manifest"],
probe: ProbeStep,
): CompareStep | undefined => {
const { request, live } = probe;
const base = {
ordinal: probe.ordinal,
method: request.method,
path: request.path,
live_status: live?.status ?? null,
};
const report = (
call: ProbeToolCall | undefined,
outcome: { env_status: number | null; differences: Difference[] },
): CompareStep | undefined =>
live === undefined ? undefined : stepOf(base, call, outcome);
// Matched in the environment's terms, so a tool whose path holds a seeded id literally still matches; each path
// parameter translates by the collection it names, where the request already fits a tool.
const fitted = matchProbeRequest(manifest, request);
const scopes =
fitted === undefined ? undefined : parameterScopes(fitted.tool);
const call = matchProbeRequest(manifest, {
...request,
path: translatePath(request.path, run.map, (index) => scopes?.get(index)),
});
if (call === undefined)
return report(call, {
env_status: null,
differences: [{ category: "no-tool", pointer: "" }],
});
const check = run.served.get(call.tool.name);
if (check === undefined)
return report(call, {
env_status: null,
differences: [{ category: "not-served", pointer: "" }],
});
const held = heldIds(run.store);
const unknownPath = call.pathArguments.some((name) => {
const value = call.args[name];
return (
typeof value === "string" && !run.map.hasEnv(value) && !held.has(value)
);
});
const refusedWith = (message: string): CompareStep | undefined =>
report(call, {
env_status: null,
differences: [
{ category: "refused", pointer: "", env: message.slice(0, 200) },
],
});
const args = environmentArguments(call, run.map);
const problem = check(args);
if (problem !== undefined)
return refusedWith(`Invalid arguments: ${problem}`);
const answered = run.answer(call.tool, args);
if (!answered.ok) return refusedWith(answered.error.message);
if (live === undefined) return undefined;
const env: Answer = {
status: answered.value.status,
body: envBody(run, answered.value),
};
const entity = entityOf(run, call.tool);
// Another call's recording holds other entities, whose ids would map wrongly and, the first pairing winning, for good.
if (answersCall(run, call, args, answered.value))
learnIds(live.body, env.body, run.map, {
heldIds: heldIds(run.store),
scope: entity.scope,
entityAt: entity.at,
});
const compared = compareAnswers(live, env, run.map, entity);
for (const key of NORMALISED_KEYS)
run.normalised[key] += compared.normalised[key];
const differences = [...compared.differences];
if (unknownPath && isSuccess(live.status) && !isSuccess(env.status))
differences.unshift({ category: "unmapped-id", pointer: "" });
return stepOf(base, call, { env_status: env.status, differences });
};
const probeReport = (
probe: { readonly label: string; readonly evidenceId: string },
run: ProbeRun,
results: CompareStep[],
unobserved: UnobservedRecord[],
): CompareProbeReport => ({
label: probe.label,
evidence_id: probe.evidenceId,
steps: results.length,
equal: results.filter((step) => step.equal).length,
ids_mapped: run.map.size,
id_conflicts: run.map.conflicts,
normalised: run.normalised,
results,
unobserved,
});
const summaryOf = (
probes: readonly CompareProbeReport[],
): CompareReport["summary"] => {
const steps = probes.flatMap((probe) => probe.results);
const categories = Object.fromEntries(
DIFFERENCE_CATEGORIES.map((category) => [category, 0]),
) as Record<DifferenceCategory, number>;
for (const step of steps)
for (const category of step.categories) categories[category] += 1;
const equal = steps.filter((step) => step.equal).length;
const ratio = (count: number): number | null =>
steps.length === 0
? null
: Math.round((count / steps.length) * 1000) / 1000;
const normalised = emptyNormalised();
for (const probe of probes)
for (const key of NORMALISED_KEYS) normalised[key] += probe.normalised[key];
return {
probes: probes.length,
steps: steps.length,
equal,
agreement: ratio(equal),
agreement_ignoring_order: ratio(
steps.filter((step) => step.equal_ignoring_order).length,
),
unobserved: probes.reduce((sum, probe) => sum + probe.unobserved.length, 0),
categories,
normalised,
};
};
const toolsOf = (
probes: readonly CompareProbeReport[],
): CompareReport["tools"] => {
const tools = new Map<
string,
{ tool: string; steps: number; equal: number }
>();
for (const step of probes.flatMap((probe) => probe.results)) {
if (step.tool === null) continue;
const entry = tools.get(step.tool) ?? {
tool: step.tool,
steps: 0,
equal: 0,
};
entry.steps += 1;
if (step.equal) entry.equal += 1;
tools.set(step.tool, entry);
}
return [...tools.values()].sort(
(left, right) =>
left.equal / left.steps - right.equal / right.steps ||
(left.tool < right.tool ? -1 : 1),
);
};
/** Compare an environment with probe captures of the application: each probe from a reset state, every request answered and compared. */
export const compareEnvironment = (input: {
readonly source: EnvironmentSource;
readonly captureValue: unknown;
readonly probes: readonly CompareProbeInput[];
readonly operation: string;
}): Result<CompareReport, AnalysisError> => {
const { source, operation } = input;
const state = prepareServedState(source, input.captureValue, {
operation,
}).state;
const catalog = servedCatalog(source, state, source.manifest.name, operation);
if (!catalog.ok) return catalog;
const served = new Map(
catalog.value.tools.map((definition) => [
definition.name,
inputCheck(definition),
]),
);
const recorded = parseCaptureEvidence(
operation,
input.captureValue,
"manifest",
);
const recordedBodies = new Map(
recorded.ok
? recorded.value.records.map((record) => [
record.ordinal,
requestBody(record),
])
: [],
);
const probes: CompareProbeReport[] = [];
for (const [index, probe] of input.probes.entries()) {
// A probe's request bodies are its call arguments, which a replay projection of a browser capture drops.
const capture = parseCaptureEvidence(operation, probe.value, "manifest");
// Without response bodies only statuses would compare, and without request bodies writes would be made empty.
const problem = !capture.ok
? capture.error.message
: !capture.value.responseBodies
? "a browser probe needs capture.network.response_body selected, or only statuses would compare."
: !capture.value.requestBodies && sendsBodies(capture.value.records)
? "a browser probe that makes POST, PUT or PATCH calls needs capture.network.request_body selected, or those calls would be made without their bodies."
: undefined;
if (!capture.ok || problem !== undefined)
return err(
inputError(
operation,
[
{
path: ["probe", index],
reason: "invalid_value",
message: `${probe.label}: ${problem}`,
},
],
"A probe file cannot be compared.",
),
);
const store =
state === undefined ? undefined : new EnvironmentStateStore(state);
const run: ProbeRun = {
source,
recordedBodies,
answer: environmentToolAnswer(source, store),
store,
served,
map: new IdMap(),
normalised: emptyNormalised(),
};
const { steps, unobserved } = probeRequests(capture.value.records);
const results = steps.flatMap(
(step) => compareStep(run, source.manifest, step) ?? [],
);
probes.push(
probeReport(
{ label: probe.label, evidenceId: capture.value.evidenceId },
run,
results,
unobserved,
),
);
}
return ok({
status: "complete",
environment: {
name: source.manifest.name,
manifest_evidence_id: source.manifestEvidenceId,
evidence_id: source.evidenceId,
state: state === undefined ? "replay-only" : "served",
},
summary: summaryOf(probes),
tools: toolsOf(probes),
probes,
});
};
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import type { JsonValue } from "../../domain/jsonValue.js";
import { compareAnswers } from "./environmentCompareDiff.js";
import { IdMap, learnIds } from "./environmentCompareIds.js";
const compare = (
live: unknown,
env: unknown,
map = new IdMap(),
status: [number, number] = [200, 200],
) =>
compareAnswers(
{ status: status[0], body: live as never },
{ status: status[1], body: env as never },
map,
);
describe("compareAnswers", () => {
it("agrees on mapped ids, clocks of one format and generated values, and counts each", () => {
const map = new IdMap();
map.learn(101, 1);
const result = compare(
{
id: 101,
created_at: "2026-10-20T10:00:00Z",
token: "3f2a9c8d7e",
uuid: "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b",
},
{
id: 1,
created_at: "2026-10-17T18:00:03Z",
token: "9b8e7d6c5a",
uuid: "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
},
map,
);
expect(result.differences).toEqual([]);
expect(result.normalised).toEqual({
ids: 1,
clocks: 1,
generated: 2,
large_bodies: 0,
unobserved_bodies: 0,
});
});
it("categorises status, missing and extra fields, values and clock formats", () => {
const result = compare(
{ name: "A", gone: 1, at: "2026-10-20T10:00:00.123+00:00" },
{ name: "B", added: true, at: "2026-10-20T10:00:00Z" },
new IdMap(),
[409, 204],
);
expect(result.differences.map((d) => [d.category, d.pointer])).toEqual([
["status", ""],
["value", "/name"],
["missing-field", "/gone"],
["clock-format", "/at"],
["extra-field", "/added"],
]);
});
it("reports array length and order and compares paired items", () => {
const result = compare(
[
{ name: "B", n: 2 },
{ name: "A", n: 1 },
{ name: "C", n: 3 },
],
[
{ name: "A", n: 1 },
{ name: "B", n: 5 },
],
);
expect(result.differences.map((d) => [d.category, d.pointer])).toEqual([
["length", ""],
["order", ""],
["value", "/0/n"],
]);
});
it("treats an epoch number as a clock only under a time-named field", () => {
expect(
compare({ updated: 1760000000 }, { updated: 1760000999 }).differences,
).toEqual([]);
expect(
compare({ size: 1760000000 }, { size: 1760000999 }).differences.map(
(d) => d.category,
),
).toEqual(["value"]);
});
it("compares an object keyed by mapped ids under the environment's keys", () => {
const map = new IdMap();
map.learn("liveTable", "envTable");
expect(
compare(
{ tables: { liveTable: { name: "T" } } },
{ tables: { envTable: { name: "T" } } },
map,
).differences,
).toEqual([]);
});
it("reports a reordered set of scalars as order alone and reads epoch array items as clocks", () => {
expect(
compare(
{ access: ["a", "b", "c"] },
{ access: ["c", "a", "b"] },
).differences.map((d) => [d.category, d.pointer]),
).toEqual([["order", "/access"]]);
expect(
compare({ stats: [[1792450800, 3]] }, { stats: [[1792285200, 3]] })
.differences,
).toEqual([]);
});
it("compares a live body over the replay limit by status only and counts it", () => {
const result = compareAnswers(
{ status: 200, body: '{"data":[', cut: true },
{ status: 200, body: null },
new IdMap(),
);
expect(result.differences).toEqual([]);
expect(result.normalised.large_bodies).toBe(1);
});
it("compares a live body the probe did not record by status only and counts it", () => {
const unrecorded = { status: 200, body: null, unobserved: true };
const result = compareAnswers(
unrecorded,
{ status: 200, body: { id: 1 } },
new IdMap(),
);
expect(result.differences).toEqual([]);
expect(result.normalised.unobserved_bodies).toBe(1);
expect(
compareAnswers(unrecorded, { status: 404, body: null }, new IdMap())
.differences,
).toEqual([{ category: "status", pointer: "", live: "200", env: "404" }]);
});
});
describe("scalar sets of mapped ids", () => {
it("differ only in order once live ids are mapped", () => {
const map = new IdMap();
map.learn(101, 1);
map.learn(102, 2);
const compared = compareAnswers(
{ status: 200, body: { ids: [101, 102] } },
{ status: 200, body: { ids: [2, 1] } },
map,
);
expect(compared.differences).toEqual([
{ category: "order", pointer: "/ids" },
]);
expect(compared.normalised.ids).toBe(2);
});
});
describe("ids two collections reuse", () => {
it("compares an entity's own id within the call's collection and any other id by the probe's mapping", () => {
const map = new IdMap();
map.learn(101, 1, "/api/books");
map.learn(101, 2, "/api/authors");
const answer = (body: JsonValue) => ({ status: 200, body });
expect(
compareAnswers(answer({ id: 101 }), answer({ id: 2 }), map, {
scope: "/api/authors",
}).differences,
).toEqual([]);
expect(
compareAnswers(answer({ book_id: 101 }), answer({ book_id: 2 }), map)
.differences,
).toEqual([
{ category: "value", pointer: "/book_id", live: "101", env: "2" },
]);
});
});
describe("wrapped answers", () => {
it("compares the identity of an entity inside an envelope within the call's collection", () => {
const map = new IdMap();
map.learn(101, 1, "/api/books");
learnIds(
{ data: [{ id: 101, name: "Bob" }] },
{ data: [{ id: 2, name: "Bob" }] },
map,
{ heldIds: new Set(), scope: "/api/authors", entityAt: "/data" },
);
expect(map.env(101, "/api/authors")).toBe(2);
expect(
compareAnswers(
{ status: 200, body: { data: { id: 101, name: "Bob" } } },
{ status: 200, body: { data: { id: 2, name: "Bob" } } },
map,
{ scope: "/api/authors", at: "/data" },
).differences,
).toEqual([]);
});
});
@@ -0,0 +1,315 @@
import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js";
import {
isUuid,
readClock,
type ClockFormat,
} from "../../domain/analysisView/environmentStateWriteValues.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import {
IDENTITY_FIELD,
pairElements,
translateValue,
type IdMap,
} from "./environmentCompareIds.js";
export const DIFFERENCE_CATEGORIES = [
"status",
"missing-field",
"extra-field",
"value",
"length",
"order",
"clock-format",
"refused",
"not-served",
"no-tool",
"unmapped-id",
] as const;
export type DifferenceCategory = (typeof DIFFERENCE_CATEGORIES)[number];
/** One way an environment answer differs from the live one: where, and both values cut short. */
export interface Difference {
readonly category: DifferenceCategory;
readonly pointer: string;
readonly live?: string;
readonly env?: string;
}
/** Values the comparison treated as equal although they differ: mapped ids, clocks of one format, generated values. */
export interface Normalised {
ids: number;
clocks: number;
generated: number;
/** Live bodies over the replay limit, compared by status only. */
large_bodies: number;
/** Live bodies the probe did not record, compared by status only. */
unobserved_bodies: number;
}
/** At most this many differences are kept per step. */
export const STEP_DIFFERENCE_LIMIT = 20;
const VALUE_CHARS = 120;
const TIME_FIELD = /(at|time|date|timestamp|created|updated|modified)$/iu;
const isObject = (value: JsonValue | undefined): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
const shown = (value: JsonValue | undefined): string => {
const text = value === undefined ? "<absent>" : JSON.stringify(value);
return text.length > VALUE_CHARS ? `${text.slice(0, VALUE_CHARS)}…` : text;
};
const escapePointer = (key: string): string =>
key.replaceAll("~", "~0").replaceAll("/", "~1");
const formatKey = (format: ClockFormat): string =>
format.kind === "epoch"
? `epoch-${format.unit}`
: `iso${format.separator}${String(format.fraction)}${format.zone === "" ? "" : format.zone === "Z" ? "Z" : "offset"}`;
/** The format of a clock value: an ISO-8601 string, or an epoch number under a time-named field or as an array item. */
const clockFormat = (
value: JsonValue,
key: string | undefined,
): string | undefined => {
if (typeof value === "number" && key !== undefined && !TIME_FIELD.test(key))
return undefined;
const clock = readClock(value);
return clock === undefined ? undefined : formatKey(clock.format);
};
const characterClasses = (text: string): string =>
[
...new Set(
text
.replaceAll(/\d/gu, "0")
.replaceAll(/[a-z]/gu, "a")
.replaceAll(/[A-Z]/gu, "A"),
),
]
.sort()
.join("");
/** Two values each side generated on its own: both UUIDs, or one length of eight or more with the same character classes and a digit. */
const generatedPair = (live: string, env: string): boolean =>
(isUuid(live) && isUuid(env)) ||
(live.length === env.length &&
live.length >= 8 &&
/\d/u.test(live) &&
/\d/u.test(env) &&
characterClasses(live) === characterClasses(env));
class Differ {
readonly differences: Difference[] = [];
readonly normalised: Normalised = {
ids: 0,
clocks: 0,
generated: 0,
large_bodies: 0,
unobserved_bodies: 0,
};
/** The answer's own entities: where they sit (the object, or the array of them, at `at`) and the collection the call names, whose own mapping their identities compare by. */
constructor(
readonly map: IdMap,
readonly entity: {
readonly scope: string | undefined;
readonly at: string;
},
) {}
add(difference: Difference): void {
this.differences.push(difference);
}
scalar(
live: JsonValue,
env: JsonValue,
pointer: string,
at: { readonly key?: string; readonly own?: boolean } = {},
): void {
if (live === env) return;
const { key } = at;
if (
(typeof live === "string" || typeof live === "number") &&
(typeof env === "string" || typeof env === "number")
) {
// An entity's own identity compares within the call's collection; any other id by the probe's mapping.
const mapped = this.map.env(
live,
at.own === true && key !== undefined && IDENTITY_FIELD.test(key)
? this.entity.scope
: undefined,
);
if (mapped !== undefined && String(mapped) === String(env)) {
this.normalised.ids += 1;
return;
}
const liveClock = clockFormat(live, key);
const envClock = clockFormat(env, key);
if (liveClock !== undefined && envClock !== undefined) {
if (liveClock === envClock) this.normalised.clocks += 1;
else
this.add({
category: "clock-format",
pointer,
live: shown(live),
env: shown(env),
});
return;
}
if (
typeof live === "string" &&
typeof env === "string" &&
!this.map.hasLive(live) &&
!this.map.hasEnv(env) &&
generatedPair(live, env)
) {
this.normalised.generated += 1;
return;
}
}
this.add({
category: "value",
pointer,
live: shown(live),
env: shown(env),
});
}
array(live: JsonValue[], env: JsonValue[], pointer: string): void {
const own = pointer === this.entity.at;
if (this.sameItems(live, env, pointer)) return;
const pairs = pairElements(live, env);
if (live.length !== env.length || pairs.length !== live.length)
this.add({
category: "length",
pointer,
live: `${String(live.length)} items`,
env: `${String(env.length)} items (${String(pairs.length)} paired)`,
});
const envOrder = pairs.map(([, right]) => right);
if (
envOrder.some((right, index) => index > 0 && right < envOrder[index - 1]!)
)
this.add({ category: "order", pointer });
for (const [left, right] of pairs)
this.value(live[left]!, env[right]!, `${pointer}/${String(left)}`, {
own,
});
}
/** Arrays of scalars holding the same items in another order differ only in order, as sets an application returns unordered do. */
sameItems(live: JsonValue[], env: JsonValue[], pointer: string): boolean {
const scalar = (item: JsonValue): boolean =>
item === null || typeof item !== "object";
if (live.length !== env.length || !live.every(scalar) || !env.every(scalar))
return false;
// Live ids compare in the environment's terms, as scalar values do.
const mapped = live.map((item) => translateValue(item, this.map));
const key = (items: JsonValue[]): string =>
JSON.stringify(items.map((item) => JSON.stringify(item)).sort());
if (key(mapped) !== key(env)) return false;
this.normalised.ids += mapped.filter(
(item, index) => item !== live[index],
).length;
if (JSON.stringify(mapped) !== JSON.stringify(env))
this.add({ category: "order", pointer });
return true;
}
object(
live: JsonObject,
env: JsonObject,
pointer: string,
own = false,
): void {
const self = own || pointer === this.entity.at;
// An object keyed by ids compares each member under the environment's key.
const envKey = (key: string): string => {
const mapped = this.map.env(key);
return mapped !== undefined && Object.hasOwn(env, String(mapped))
? String(mapped)
: key;
};
const matched = new Set<string>();
for (const [key, value] of Object.entries(live)) {
const child = `${pointer}/${escapePointer(key)}`;
const other = envKey(key);
if (!Object.hasOwn(env, other))
this.add({
category: "missing-field",
pointer: child,
live: shown(value),
});
else {
matched.add(other);
this.value(value, env[other]!, child, { key, own: self });
}
}
for (const [key, value] of Object.entries(env))
if (!matched.has(key))
this.add({
category: "extra-field",
pointer: `${pointer}/${escapePointer(key)}`,
env: shown(value),
});
}
/** `own` marks an item of the entity array, or with `key` a field of an own entity; a field's own children are not. */
value(
live: JsonValue,
env: JsonValue,
pointer: string,
at: { readonly key?: string; readonly own?: boolean } = {},
): void {
if (Array.isArray(live) && Array.isArray(env))
this.array(live, env, pointer);
else if (isObject(live) && isObject(env))
this.object(live, env, pointer, at.own === true && at.key === undefined);
else if (
Array.isArray(live) ||
Array.isArray(env) ||
isObject(live) ||
isObject(env)
)
this.add({
category: "value",
pointer,
live: shown(live),
env: shown(env),
});
else this.scalar(live, env, pointer, at);
}
}
/** One answer: its status, and its JSON body, its text, or null without one. */
export interface Answer {
readonly status: number | null;
readonly body: JsonValue;
/** Set when the body was over the replay limit, so only a prefix is known. */
readonly cut?: boolean;
/** Set when the probe did not record the body, so only the status is known. */
readonly unobserved?: boolean;
}
/** Compare a live answer with the environment's after normalisation. */
export const compareAnswers = (
live: Answer,
env: Answer,
map: IdMap,
entity: { readonly scope?: string; readonly at?: string } = {},
): { readonly differences: Difference[]; readonly normalised: Normalised } => {
const differ = new Differ(map, { scope: entity.scope, at: entity.at ?? "" });
if (live.status !== env.status)
differ.add({
category: "status",
pointer: "",
live: String(live.status),
env: String(env.status),
});
if (live.cut === true) differ.normalised.large_bodies += 1;
else if (live.unobserved === true) differ.normalised.unobserved_bodies += 1;
else differ.value(live.body, env.body, "");
return { differences: differ.differences, normalised: differ.normalised };
};
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import {
IdMap,
learnIds,
pairElements,
translatePath,
translateValue,
} from "./environmentCompareIds.js";
describe("IdMap", () => {
it("keeps the first pairing of either side and counts conflicting ones", () => {
const map = new IdMap();
map.learn(101, 1);
map.learn("101", 1);
map.learn(101, 2);
map.learn(102, 1);
expect(map.env(101)).toBe(1);
expect(map.env("101")).toBe(1);
expect(map.hasEnv(1)).toBe(true);
expect(map.size).toBe(1);
expect(map.conflicts).toBe(2);
});
it("ignores a pair of equal values", () => {
const map = new IdMap();
map.learn("same", "same");
expect(map.size).toBe(0);
});
});
describe("pairElements", () => {
it("pairs objects by the shared unique string field that pairs the most", () => {
const live = [
{ id: 102, name: "Second", kind: "book" },
{ id: 101, name: "First", kind: "book" },
];
const env = [
{ id: 1, name: "First", kind: "book" },
{ id: 2, name: "Second", kind: "book" },
{ id: 3, name: "Third", kind: "book" },
];
expect(pairElements(live, env)).toEqual([
[0, 1],
[1, 0],
]);
});
it("pairs by position without a shared key or for scalar items", () => {
expect(pairElements([{ n: 1 }, { n: 2 }], [{ n: 3 }])).toEqual([[0, 0]]);
expect(pairElements(["a", "b"], ["c", "d"])).toEqual([
[0, 0],
[1, 1],
]);
});
});
describe("learnIds", () => {
it("maps identifier fields of paired list items and of two single answers", () => {
const map = new IdMap();
learnIds(
[
{ id: 102, name: "Second", owner_id: "u-9" },
{ id: 101, name: "First", owner_id: "u-9" },
],
[
{ id: 1, name: "First", owner_id: "u-1" },
{ id: 2, name: "Second", owner_id: "u-1" },
],
map,
{ heldIds: new Set() },
);
expect([map.env(101), map.env(102), map.env("u-9")]).toEqual([1, 2, "u-1"]);
learnIds(
{ data: { uid: "x9", ref: "r-1" } },
{ data: { uid: "e3", ref: "r-2" } },
map,
{ heldIds: new Set(["r-2"]) },
);
expect(map.env("r-1")).toBe("r-2");
expect(map.env("x9")).toBeUndefined();
learnIds({ workers: 0 }, { workers: 1 }, map, { heldIds: new Set(["1"]) });
expect(map.env(0)).toBeUndefined();
});
it("learns nothing from items that share a key but no value, or differ in count without one", () => {
const map = new IdMap();
learnIds([{ code: "kelp", id: 1 }], [{ code: "menu", id: 2 }], map, {
heldIds: new Set(["menu"]),
});
learnIds(
[{ n: 1, id: 3 }],
[
{ n: 1, id: 4 },
{ n: 2, id: 5 },
],
map,
{ heldIds: new Set() },
);
expect(map.size).toBe(0);
learnIds([{ n: 1, id: 3 }], [{ n: 1, id: 4 }], map, { heldIds: new Set() });
expect(map.env(3)).toBe(4);
});
});
describe("learnIds over objects keyed by ids", () => {
it("pairs entries by a natural key in their values and maps the keys", () => {
const map = new IdMap();
learnIds(
{
data: {
liveA: { id: "liveA", name: "Lamp Oil" },
liveB: { id: "liveB", name: "Quarry" },
},
},
{
data: {
envB: { id: "envB", name: "Quarry" },
envA: { id: "envA", name: "Lamp Oil" },
},
},
map,
{ heldIds: new Set() },
);
expect([map.env("liveA"), map.env("liveB")]).toEqual(["envA", "envB"]);
});
});
describe("translateValue", () => {
it("replaces mapped live identifiers at any depth in the original value's type", () => {
const map = new IdMap();
map.learn(101, 1);
map.learn("abc", "xyz");
expect(
translateValue({ ids: [101, "101", "abc"], name: "abc def", n: 5 }, map),
).toEqual({ ids: [1, "1", "xyz"], name: "abc def", n: 5 });
});
});
describe("translatePath", () => {
it("replaces segments that decode to a mapped live id and re-encodes them", () => {
const map = new IdMap();
map.learn("live@1", "env@1");
map.learn(7, 3);
expect(translatePath("/api/releases/live%401/deploys/7/", map)).toBe(
"/api/releases/env%401/deploys/3/",
);
expect(translatePath("/api/%E0%A4%A/x", map)).toBe("/api/%E0%A4%A/x");
});
});
describe("ids two collections reuse", () => {
it("keeps each collection's pairing of a live id and translates a path id by its collection", () => {
const map = new IdMap();
learnIds([{ id: 101, name: "First" }], [{ id: 1, name: "First" }], map, {
heldIds: new Set(),
scope: "/api/books",
});
learnIds([{ id: 101, name: "Bob" }], [{ id: 2, name: "Bob" }], map, {
heldIds: new Set(),
scope: "/api/authors",
});
expect(map.env(101, "/api/authors")).toBe(2);
expect(map.env(101)).toBe(1);
expect(
translatePath("/api/authors/101", map, (index) =>
index === 2 ? "/api/authors" : undefined,
),
).toBe("/api/authors/2");
});
});
describe("objects keyed by ids that overlap", () => {
it("pairs entries by a natural key when some re-seeded keys are reused, and only when every entry pairs", () => {
const map = new IdMap();
learnIds(
{ "2": { name: "A" }, "3": { name: "B" } },
{ "1": { name: "A" }, "2": { name: "B" } },
map,
{ heldIds: new Set() },
);
expect([map.env("2"), map.env("3")]).toEqual(["1", "2"]);
const partial = new IdMap();
learnIds(
{ "2": { name: "A" }, "3": { name: "C" } },
{ "1": { name: "A" }, "2": { name: "B" } },
partial,
{ heldIds: new Set() },
);
expect(partial.size).toBe(0);
});
});
describe("ordinary objects whose fields overlap", () => {
it("does not read their fields as ids, even when every value pairs", () => {
const map = new IdMap();
learnIds(
{ current: { name: "A" }, previous: { name: "B" } },
{ current: { name: "B" }, next: { name: "A" } },
map,
{ heldIds: new Set() },
);
expect(map.size).toBe(0);
});
});
@@ -0,0 +1,368 @@
import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js";
import { isUuid } from "../../domain/analysisView/environmentStateWriteValues.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { ProbeToolCall } from "./environmentCompareMatch.js";
/** Field names that hold an identifier: `id`, `uuid`, `guid`, or names ending in `id` or `_id`. */
export const ID_FIELD = /^(id|uuid|guid)$|(_id|Id|ID|_uuid|Uuid)$/u;
const isObject = (value: JsonValue | undefined): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isScalarId = (value: JsonValue | undefined): value is string | number =>
(typeof value === "string" && value !== "") ||
(typeof value === "number" && Number.isFinite(value));
/** Entity identity fields, whose values an answer's own collection scopes. */
export const IDENTITY_FIELD = /^(id|uuid|guid)$/u;
/**
* Live identifier values and the environment's values they stand for, in both directions: one map for the whole probe,
* and one per collection for the ids of its own entities, since two collections can reuse a live id for entities the
* environment holds under different ids.
*/
export class IdMap {
readonly #toEnv = new Map<string, string | number>();
readonly #toLive = new Map<string, string | number>();
readonly #scoped = new Map<string, Map<string, string | number>>();
readonly #envValues = new Set<string>();
#conflicts = 0;
/** Pair a live value with an environment value; the first pairing of either side wins, and within a collection the first of that collection. */
learn(live: string | number, env: string | number, scope?: string): void {
const liveKey = String(live);
const envKey = String(env);
if (liveKey === envKey) return;
if (scope !== undefined) {
const scoped = this.#scoped.get(scope) ?? new Map();
if (!scoped.has(liveKey)) scoped.set(liveKey, env);
this.#scoped.set(scope, scoped);
this.#envValues.add(envKey);
}
const known = this.#toEnv.get(liveKey);
if (known !== undefined) {
if (String(known) !== envKey) this.#conflicts += 1;
return;
}
if (this.#toLive.has(envKey)) {
this.#conflicts += 1;
return;
}
this.#toEnv.set(liveKey, env);
this.#toLive.set(envKey, live);
this.#envValues.add(envKey);
}
/** The environment's value for a live one: the collection's own when a scope is given and it knows one, else the probe's. */
env(live: string | number, scope?: string): string | number | undefined {
const liveKey = String(live);
return (
(scope === undefined
? undefined
: this.#scoped.get(scope)?.get(liveKey)) ?? this.#toEnv.get(liveKey)
);
}
hasLive(value: string | number): boolean {
const key = String(value);
return (
this.#toEnv.has(key) ||
[...this.#scoped.values()].some((scoped) => scoped.has(key))
);
}
hasEnv(value: string | number): boolean {
return this.#envValues.has(String(value));
}
get size(): number {
return this.#toEnv.size;
}
get conflicts(): number {
return this.#conflicts;
}
}
/** A field every object element has as a non-empty string, unique on its side; not an identifier, which differs across sides. */
const keyCandidates = (items: readonly JsonObject[]): Set<string> => {
const candidates = new Set<string>();
const first = items[0];
if (first === undefined) return candidates;
for (const key of Object.keys(first)) {
if (ID_FIELD.test(key)) continue;
const values = items.map((item) => item[key]);
if (
values.every((value) => typeof value === "string" && value !== "") &&
new Set(values).size === values.length
)
candidates.add(key);
}
return candidates;
};
/** How two arrays pair: by a natural key, by position because no key is shared, or by position although a shared key paired nothing. */
interface Pairing {
readonly pairs: Array<readonly [number, number]>;
readonly by: "key" | "position" | "unmatched";
}
const pairing = (
live: readonly JsonValue[],
env: readonly JsonValue[],
): Pairing => {
const byPosition = (by: Pairing["by"]): Pairing => ({
pairs: Array.from(
{ length: Math.min(live.length, env.length) },
(_, index) => [index, index] as const,
),
by,
});
if (!live.every(isObject) || !env.every(isObject))
return byPosition("position");
const liveItems = live as readonly JsonObject[];
const envItems = env as readonly JsonObject[];
const envKeys = keyCandidates(envItems);
const shared = [...keyCandidates(liveItems)].filter((key) =>
envKeys.has(key),
);
let best: Array<readonly [number, number]> = [];
for (const key of shared.sort()) {
const envIndex = new Map(envItems.map((item, index) => [item[key], index]));
const pairs: Array<readonly [number, number]> = [];
for (const [index, item] of liveItems.entries()) {
const match = envIndex.get(item[key]);
if (match !== undefined) pairs.push([index, match]);
}
if (pairs.length > best.length) best = pairs;
}
if (best.length > 0) return { pairs: best, by: "key" };
return byPosition(shared.length > 0 ? "unmatched" : "position");
};
/**
* Pair two arrays' object elements by the natural key that pairs the most, as [live index, env index]; arrays of other
* values, or with no shared key, pair by position up to the shorter length.
*/
export const pairElements = (
live: readonly JsonValue[],
env: readonly JsonValue[],
): Array<readonly [number, number]> => pairing(live, env).pairs;
/** Whether an object's keys are evidently identifiers: digits or UUIDs, or each the identity of the entry it keys. */
const keyedByIds = (
entries: readonly (readonly [string, JsonValue])[],
): boolean =>
entries.every(
([key, value]) =>
/^\d+$/u.test(key) ||
isUuid(key) ||
(isObject(value) &&
Object.entries(value).some(
([field, held]) =>
IDENTITY_FIELD.test(field) &&
isScalarId(held) &&
String(held) === key,
)),
);
/**
* Two objects keyed by ids, whose object values pair by a natural key: each pair maps the live key to the environment's
* and is learned from in turn. Returns whether the objects were read that way.
*/
const learnKeyedEntries = (
live: JsonObject,
env: JsonObject,
map: IdMap,
heldIds: ReadonlySet<string>,
): boolean => {
const liveEntries = Object.entries(live);
const envEntries = Object.entries(env);
const shared = liveEntries.filter(([key]) => Object.hasOwn(env, key)).length;
if (
liveEntries.length === 0 ||
envEntries.length === 0 ||
shared === liveEntries.length ||
!liveEntries.every(([, value]) => isObject(value)) ||
!envEntries.every(([, value]) => isObject(value))
)
return false;
const paired = pairing(
liveEntries.map(([, value]) => value),
envEntries.map(([, value]) => value),
);
// Re-seeded ids can reuse a key for another entity, but ordinary fields overlap too: keys that overlap are read as ids
// only when they evidently are ids and every entry pairs.
if (
paired.by !== "key" ||
(shared > 0 &&
(paired.pairs.length !== liveEntries.length ||
!keyedByIds(liveEntries) ||
!keyedByIds(envEntries)))
)
return false;
for (const [left, right] of paired.pairs) {
const [liveKey, liveValue] = liveEntries[left]!;
const [envKey, envValue] = envEntries[right]!;
map.learn(liveKey, envKey);
learnIds(liveValue, envValue, map, { heldIds });
}
return true;
};
/** What learning reads besides the two values: the map it adds to, the ids the state holds, and where the answer's own entities sit. */
interface LearnContext {
readonly map: IdMap;
readonly heldIds: ReadonlySet<string>;
readonly scope: string | undefined;
/** JSON pointer of the answer's own entity, or of the array holding its entities. */
readonly entityAt: string;
}
/** Two values under one key as a pair of ids, when they are: an identifier field, or outside one only a string the state holds as an id, since a counter that equals an id is not one. */
const idPair = (
key: string,
value: JsonValue,
other: JsonValue | undefined,
heldIds: ReadonlySet<string>,
): readonly [string | number, string | number] | undefined =>
isScalarId(value) &&
isScalarId(other) &&
(ID_FIELD.test(key) ||
(typeof value === "string" &&
typeof other === "string" &&
heldIds.has(other)))
? [value, other]
: undefined;
const childPointer = (at: string, key: string | number): string =>
`${at}/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`;
const learnAt = (
live: JsonValue | undefined,
env: JsonValue | undefined,
context: LearnContext,
place: { readonly at: string; readonly own: boolean },
): void => {
if (Array.isArray(live) && Array.isArray(env)) {
// Items paired only by position stand for the same entities only when nothing could pair them by a key and the
// arrays are the same length; otherwise a filtered or reordered list would teach wrong ids.
const paired = pairing(live, env);
if (
paired.by === "unmatched" ||
(paired.by === "position" && live.length !== env.length)
)
return;
for (const [left, right] of paired.pairs)
learnAt(live[left], env[right], context, {
at: childPointer(place.at, left),
own: place.at === context.entityAt,
});
return;
}
if (!isObject(live) || !isObject(env)) return;
if (learnKeyedEntries(live, env, context.map, context.heldIds)) return;
const own = place.own || place.at === context.entityAt;
for (const [key, value] of Object.entries(live)) {
if (!Object.hasOwn(env, key)) continue;
const other = env[key];
const pair = idPair(key, value, other, context.heldIds);
if (pair !== undefined)
context.map.learn(
...pair,
own && IDENTITY_FIELD.test(key) ? context.scope : undefined,
);
else
learnAt(value, other, context, {
at: childPointer(place.at, key),
own: false,
});
}
};
/**
* Learn identifier pairs from the live and environment answers to one call: identifier fields, and strings the state holds
* as ids, of objects that pair, at any depth. The identity fields of the answer's own entities, the object or the items of
* the array at `entityAt` (the root by default), are also learned under `scope`, the collection the call names.
*/
export const learnIds = (
live: JsonValue | undefined,
env: JsonValue | undefined,
map: IdMap,
known: {
/** Ids the environment's state holds, which mark a string outside identifier fields as an id. */
readonly heldIds: ReadonlySet<string>;
readonly scope?: string;
readonly entityAt?: string;
},
): void =>
learnAt(
live,
env,
{
map,
heldIds: known.heldIds,
scope: known.scope,
entityAt: known.entityAt ?? "",
},
{ at: "", own: false },
);
/** A value with every string or number equal to a mapped live identifier replaced by the environment's value, in its own type. */
export const translateValue = (value: JsonValue, map: IdMap): JsonValue => {
if (Array.isArray(value))
return value.map((item) => translateValue(item, map));
if (isObject(value))
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
translateValue(item, map),
]),
);
if (!isScalarId(value)) return value;
const env = map.env(value);
if (env === undefined) return value;
return typeof value === "string"
? String(env)
: typeof env === "number"
? env
: value;
};
/** The arguments the environment is called with: path values as matched, already in its terms, paging values as sent, and every other value translated. */
export const environmentArguments = (
call: ProbeToolCall,
map: IdMap,
): Record<string, JsonValue> =>
Object.fromEntries(
Object.entries(call.args).map(([name, value]) => [
name,
call.pathArguments.includes(name) || call.pagingArguments.includes(name)
? value
: translateValue(value, map),
]),
);
/**
* A request path with every segment that decodes to a mapped live identifier replaced by the environment's value,
* encoded; `scopeAt` names the collection of the segment at a position after the leading slash, whose own mapping wins.
*/
export const translatePath = (
path: string,
map: IdMap,
scopeAt: (index: number) => string | undefined = () => undefined,
): string =>
path
.split("/")
.map((segment, index) => {
let decoded = segment;
try {
decoded = decodeURIComponent(segment);
} catch {
return segment;
}
const env =
decoded === "" ? undefined : map.env(decoded, scopeAt(index - 1));
return env === undefined ? segment : encodeURIComponent(String(env));
})
.join("/");
@@ -0,0 +1,141 @@
import { isPagingParameter } from "../../domain/analysisView/environmentStatePaging.js";
import { segmentRule } from "../../domain/analysisView/webNetworkRouteTemplates.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { decodedPathSegment } from "./environmentReplay.js";
import type {
EnvironmentManifest,
EnvironmentManifestTool,
} from "./environmentSource.js";
import { environmentToolArguments } from "./environmentTools.js";
const FORMAT_RULES: ReadonlySet<string> = new Set([
"numeric",
"uuid",
"hex",
"iso-date",
]);
/** One request of a probe capture, as the comparison replays it. */
export interface ProbeRequest {
readonly method: string;
readonly path: string;
/** WHATWG query spelling including `?`, or null. */
readonly query: string | null;
readonly body: JsonValue | undefined;
}
/** A probe request as a call of one manifest tool. */
export interface ProbeToolCall {
readonly tool: EnvironmentManifestTool;
readonly args: Record<string, JsonValue>;
/** Argument names of the path values, in path order. */
readonly pathArguments: readonly string[];
/** Argument names of query values that page a list: counts, never ids. */
readonly pagingArguments: readonly string[];
/** Query names the tool does not take; the call drops them. */
readonly ignoredQuery: readonly string[];
}
/** A tool's path segments up to `end`, a parameter as `{}`, so tools naming one parameter differently share collections. */
const collectionAt = (tool: EnvironmentManifestTool, end: number): string => {
const params = new Set(tool.params.map((param) => param.index));
return `/${tool.path_template
.slice(1)
.split("/")
.slice(0, end)
.map((segment, index) => (params.has(index) ? "{}" : segment))
.join("/")}`;
};
/** The collection a call of the tool names: its path, less the trailing parameter of an item route. */
export const collectionScope = (tool: EnvironmentManifestTool): string => {
const length = tool.path_template.slice(1).split("/").length;
return collectionAt(
tool,
tool.params.some((param) => param.index === length - 1)
? length - 1
: length,
);
};
/** The collection each path parameter names an entity of: the path before it, by the parameter's position. */
export const parameterScopes = (
tool: EnvironmentManifestTool,
): ReadonlyMap<number, string> =>
new Map(
tool.params.map((param) => [param.index, collectionAt(tool, param.index)]),
);
interface Fit {
readonly tool: EnvironmentManifestTool;
readonly literals: number;
}
/** How a path fits a tool's template: the literal segments it matches, or undefined when it does not fit. */
const fitOf = (
tool: EnvironmentManifestTool,
segments: readonly string[],
): Fit | undefined => {
const template = tool.path_template.slice(1).split("/");
if (template.length !== segments.length) return undefined;
const params = new Map(tool.params.map((param) => [param.index, param]));
let literals = 0;
for (const [index, part] of template.entries()) {
const segment = segments[index]!;
const param = params.get(index);
if (param === undefined) {
if (part !== segment && part !== decodedPathSegment(segment))
return undefined;
literals += 1;
} else if (
// Replay matches a path value decoded, so its format is the decoded value's.
FORMAT_RULES.has(param.rule) &&
segmentRule(decodedPathSegment(segment)) !== param.rule
)
return undefined;
else if (segment === "") return undefined;
}
return { tool, literals };
};
/** The tool of the request's method whose template fits the path with the most literal segments; the manifest's order breaks ties. */
export const matchProbeRequest = (
manifest: EnvironmentManifest,
request: ProbeRequest,
): ProbeToolCall | undefined => {
const segments = request.path.slice(1).split("/");
let best: Fit | undefined;
for (const tool of manifest.tools) {
if (tool.method !== request.method) continue;
const fit = fitOf(tool, segments);
if (
fit !== undefined &&
(best === undefined || fit.literals > best.literals)
)
best = fit;
}
if (best === undefined) return undefined;
const { tool } = best;
const names = environmentToolArguments(tool);
const args: Record<string, JsonValue> = {};
const pathArguments: string[] = [];
for (const param of tool.params) {
const name = names.path.get(param.name) ?? param.name;
args[name] = decodedPathSegment(segments[param.index]!);
pathArguments.push(name);
}
const ignoredQuery: string[] = [];
const pagingArguments: string[] = [];
if (request.query !== null)
for (const [key, value] of new URLSearchParams(request.query.slice(1))) {
const name = names.query.get(key);
if (name === undefined) ignoredQuery.push(key);
else if (!Object.hasOwn(args, name)) {
args[name] = value;
if (isPagingParameter(key)) pagingArguments.push(name);
}
}
if (names.body !== undefined && request.body !== undefined)
args[names.body] = request.body;
return { tool, args, pathArguments, pagingArguments, ignoredQuery };
};
@@ -0,0 +1,177 @@
import { z } from "zod";
import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
import {
primaryOrigin,
type OriginStats,
} from "../../domain/analysisView/environmentManifestOrigin.js";
import {
mediaTypeEssence,
readRecordedRequestLine,
type RecordedRequestLine,
type RequestLineExclusion,
} from "../../domain/analysisView/webNetworkRouteTemplates.js";
import { parseExactJson } from "../../domain/exactJsonNumber.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js";
import type { Answer } from "./environmentCompareDiff.js";
import type { ProbeRequest } from "./environmentCompareMatch.js";
import {
recordedExchange,
replayedBody,
type ReplayedBody,
} from "./environmentSource.js";
/** A probe record left uncompared: without a usable request line, or at another origin than the application's, it is not replayed either; without an answer it is replayed, so the state follows, but nothing is compared. */
export interface UnobservedRecord {
readonly ordinal: number;
readonly reason: RequestLineExclusion | "other-origin" | "no-response";
}
/** One probe request with the application's answer, absent when the probe recorded none. */
export interface ProbeStep {
readonly ordinal: number;
readonly request: ProbeRequest;
readonly live?: Answer;
}
/** Statuses whose answers carry no body, so a missing body is known to be empty. */
const BODYLESS_STATUSES: ReadonlySet<number> = new Set([204, 205, 304]);
const startedSchema = z.object({ startedDateTime: z.string() });
const postDataSchema = z.object({
request: z.object({
postData: z.object({ mimeType: z.string(), text: z.string() }),
}),
});
const methodSchema = z.object({ request: z.object({ method: z.string() }) });
const startedMs = (record: WebNetworkCaptureRecord): number | undefined => {
const started = startedSchema.safeParse(record.reported);
const ms = started.success ? Date.parse(started.data.startedDateTime) : NaN;
return Number.isFinite(ms) ? ms : undefined;
};
/** Whether a probe makes calls that carry a request body: a POST, PUT or PATCH. */
export const sendsBodies = (
records: readonly WebNetworkCaptureRecord[],
): boolean =>
records.some((record) => {
const parsed = methodSchema.safeParse(record.reported);
return (
parsed.success &&
["POST", "PUT", "PATCH"].includes(parsed.data.request.method)
);
});
/** The JSON a request body holds, its text when it is not JSON, or undefined without one. */
export const requestBody = (
record: WebNetworkCaptureRecord,
): JsonValue | undefined => {
const parsed = postDataSchema.safeParse(record.reported);
if (!parsed.success) return undefined;
const { mimeType, text } = parsed.data.request.postData;
return isJsonLike(mediaTypeEssence(mimeType))
? (parseExactJson(text) ?? text)
: text;
};
/** A body over the replay limit, text or bytes: only a prefix is known, so only the status compares. */
export const isCut = (body: ReplayedBody): boolean =>
body.kind === "cut" || (body.kind === "base64" && !body.whole);
/** The application's answer as an environment answer carries it: JSON, text, or null when it is empty; a body the probe did not record is marked. */
const liveAnswer = (
method: string,
status: number,
body: ReplayedBody,
): Answer => {
if (body.kind === "none")
return method === "HEAD" || BODYLESS_STATUSES.has(status)
? { status, body: null }
: { status, body: null, unobserved: true };
if (body.kind === "json") return { status, body: body.value };
if (body.kind === "base64")
return { status, body: body.encoded, cut: isCut(body) };
return {
status,
body: body.text === "" ? null : body.text,
cut: isCut(body),
};
};
/** The origin a probe calls the application at, chosen as the manifest chooses its own. */
const probeOrigin = (
lines: ReadonlyMap<number, RecordedRequestLine | null>,
): string | null => {
const origins = new Map<string, OriginStats>();
for (const line of lines.values()) {
if (line?.kind !== "line") continue;
const stats = origins.get(line.origin) ?? { records: 0, jsonLike: 0 };
stats.records += 1;
if (isJsonLike(mediaTypeEssence(line.rawMime))) stats.jsonLike += 1;
origins.set(line.origin, stats);
}
return primaryOrigin(origins);
};
/** The probe's requests in the order they were made, each with the application's answer, and the records left uncompared. */
export const probeRequests = (
records: readonly WebNetworkCaptureRecord[],
): { steps: ProbeStep[]; unobserved: UnobservedRecord[] } => {
const steps: ProbeStep[] = [];
const unobserved: UnobservedRecord[] = [];
// Without a start time for every record the probe keeps its recorded order, as state extraction does.
const starts = new Map(
records.map((record) => [record.ordinal, startedMs(record)]),
);
const timed = [...starts.values()].every((ms) => ms !== undefined);
const ordered = [...records].sort(
(left, right) =>
(timed ? starts.get(left.ordinal)! - starts.get(right.ordinal)! : 0) ||
left.ordinal - right.ordinal,
);
const lines = new Map(
records.map((record) => [
record.ordinal,
readRecordedRequestLine(record.reported, true, () => true),
]),
);
// Another origin's request goes to some other server, so it neither calls nor changes the environment.
const origin = probeOrigin(lines);
for (const record of ordered) {
const line = lines.get(record.ordinal);
const exchange = recordedExchange(record);
const { ordinal } = record;
if (line?.kind === "line" && line.origin !== origin)
unobserved.push({ ordinal, reason: "other-origin" });
else if (line?.kind !== "line" || exchange === undefined)
unobserved.push({
ordinal,
reason: line?.kind === "excluded" ? line.reason : "request-unavailable",
});
else {
const request = {
method: line.method,
path: exchange.path,
query: exchange.query,
body: requestBody(record),
};
// A request whose answer was lost may still have changed the application, so it is made but not compared.
if (exchange.status === null) {
unobserved.push({ ordinal, reason: "no-response" });
steps.push({ ordinal, request });
} else
steps.push({
ordinal,
request,
live: liveAnswer(
line.method,
exchange.status,
replayedBody(exchange),
),
});
}
}
return { steps, unobserved };
};
@@ -155,7 +155,8 @@ export const parseEvidenceFile = (
}
};
const recordedExchange = (
/** One capture record reduced to its request line and retained response body, or undefined without a request line. */
export const recordedExchange = (
record: WebNetworkCaptureRecord,
): RecordedExchange | undefined => {
const line = readRecordedRequestLine(record.reported, true, () => true);
@@ -213,16 +214,21 @@ export const parseViewEvidence = <Kind extends AnalysisViewResult["kind"]>(
});
};
/** Authenticate the evidence file and require complete capture Evidence of either kind. */
const parseCaptureEvidence = (
/** Authenticate the evidence file and require complete capture Evidence of either kind; a browser capture's request bodies are kept only for the manifest purpose. */
export const parseCaptureEvidence = (
operation: string,
value: unknown,
purpose: "manifest" | "replay" = "replay",
): Result<
{
readonly records: readonly WebNetworkCaptureRecord[];
readonly evidenceId: string;
readonly captureKind: EnvironmentCaptureKind;
readonly captureSha256: string | null;
/** Whether the capture recorded response bodies: a browser scenario does only when they were selected. */
readonly responseBodies: boolean;
/** Whether the capture recorded request bodies, likewise. */
readonly requestBodies: boolean;
},
AnalysisError
> => {
@@ -240,12 +246,14 @@ const parseCaptureEvidence = (
.safeParse(evidence.value.parameters);
if (scenario.success)
return ok({
records: scenarioCaptureRecords(scenario.data, "replay").records,
records: scenarioCaptureRecords(scenario.data, purpose).records,
evidenceId,
captureKind: "browser-scenario",
captureSha256: definition.success
? definition.data.scenario_sha256
: null,
responseBodies: scenario.data.scenario.network_content.response_body,
requestBodies: scenario.data.scenario.network_content.request_body,
});
} else if (evidence.value.operation === "inspect_web_network_capture") {
const records = immutableJsonSnapshotMember(normalized, "records");
@@ -278,6 +286,8 @@ const parseCaptureEvidence = (
evidenceId,
captureKind: "web-network-capture",
captureSha256: capture.data.artifact.sha256,
responseBodies: true,
requestBodies: true,
});
}
}
+73
View File
@@ -1,3 +1,4 @@
import { basename } from "node:path";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { EnvironmentExportResult } from "../application/environment/environmentExport.js";
import type { EnvironmentSource } from "../application/environment/environmentSource.js";
@@ -299,6 +300,77 @@ const evidenceOption = z
"Parent capture Evidence JSON path (inspect-web-network-capture or capture-browser-scenario output) the manifest cites",
);
interface CompareArguments {
readonly manifest: string;
readonly evidence: string;
readonly probes: readonly string[];
}
/** Read the environment and every probe, then compare; a refusal is the command's output. */
const compareEnvironmentCommand = async (
input: CompareArguments,
): Promise<JsonValue> => {
const read = (path: string) =>
parseCliJsonInput(path, CLI_COMMANDS.environmentCompare, undefined, {
snapshot: true,
});
const manifest = await read(input.manifest);
if (!manifest.ok) return manifest.error;
const evidence = await read(input.evidence);
if (!evidence.ok) return evidence.error;
const probes = [];
for (const path of input.probes) {
const probe = await read(path);
if (!probe.ok) return probe.error;
probes.push({ label: basename(path), value: probe.value });
}
const { loadEnvironmentSource } =
await import("../application/environment/environmentSource.js");
const source = loadEnvironmentSource(
manifest.value,
evidence.value,
CLI_COMMANDS.environmentCompare,
);
if (!source.ok) return cliError(source.error);
const { compareEnvironment } =
await import("../application/environment/environmentCompare.js");
const report = compareEnvironment({
source: source.value,
captureValue: evidence.value,
probes,
operation: CLI_COMMANDS.environmentCompare,
});
return report.ok
? (report.value as unknown as JsonValue)
: cliError(report.error);
};
/** Register the comparison of an environment with probe captures of its application. */
const registerCompareCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.environmentCompare, {
description:
"Compare an environment with probe captures of the application it was captured from: each probe is one call sequence recorded against a freshly reset application, answered by the environment from a reset state, with agreement reported per step, tool and probe",
args: z.object({ manifest: manifestArgument }),
options: z.object({
evidence: evidenceOption,
probe: z
.array(z.string())
.min(1)
.describe(
"Probe capture Evidence JSON path (inspect-web-network-capture or capture-browser-scenario output) of one call sequence made against a freshly reset application; repeat once per sequence",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.environmentCompare, () =>
compareEnvironmentCommand({
manifest: args.manifest,
evidence: options.evidence,
probes: options.probe,
}),
),
});
};
/** Register the one-shot bundle export command. */
const registerExportCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.environmentExport, {
@@ -347,6 +419,7 @@ export const registerEnvironmentCommands = (
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
registerExportCommand(cli, logger);
registerCompareCommand(cli, logger);
cli.command(CLI_COMMANDS.environmentServe, {
description:
"Serve an environment manifest as an agent environment over streamable HTTP until SIGINT or SIGTERM: read tools, and write tools whose effect the capture confirmed, answer from the state the capture observed, derived at startup, or every tool replays retained capture records with --replay-only or when the state is unavailable",
+1
View File
@@ -129,6 +129,7 @@ export const CLI_COMMANDS = Object.freeze({
evidenceExport: "evidence-export",
environmentServe: "environment-serve",
environmentExport: "environment-export",
environmentCompare: "environment-compare",
compare: "compare",
captureProcess: "capture-process",
compareProcessCaptures: "compare-process-captures",
@@ -20,6 +20,10 @@ import {
toolName,
toolNameRegistry,
} from "./environmentManifestNames.js";
import {
primaryOrigin,
type OriginStats,
} from "./environmentManifestOrigin.js";
import {
applyTemplateRules,
SEGMENT_LIMITED_RULES,
@@ -189,7 +193,7 @@ const hasParam = (parts: readonly MutablePart[]): boolean =>
export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumulator {
readonly #groups = new Map<string, GroupState>();
readonly #records: RecordLine[] = [];
readonly #origins = new Map<string, { records: number; jsonLike: number }>();
readonly #origins = new Map<string, OriginStats>();
readonly #rules: ReadonlySet<TemplateRule>;
#unrepresented = 0;
#unrepresentableBodyKey = false;
@@ -385,24 +389,6 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
return ok(undefined);
}
/** The origin with the most JSON-like records, then most records, then lowest code points. */
#primaryOrigin(): string | null {
let best: { origin: string; records: number; jsonLike: number } | null =
null;
for (const [origin, stats] of this.#origins) {
if (
best === null ||
stats.jsonLike > best.jsonLike ||
(stats.jsonLike === best.jsonLike &&
(stats.records > best.records ||
(stats.records === best.records &&
compare(origin, best.origin) < 0)))
)
best = { origin, ...stats };
}
return best?.origin ?? null;
}
#tools(groups: readonly ToolGroup[]): Tool[] {
const registry = toolNameRegistry();
return applyTemplateRules(groups, this.#rules)
@@ -464,7 +450,7 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula
/** Project the complete manifest; tools, records and exclusions are sorted deterministically. */
finish(): Result<ManifestFields, AnalysisError> {
const primary = this.#primaryOrigin();
const primary = primaryOrigin(this.#origins);
const toolGroups: GroupState[] = [];
const exclusions = new Map<string, Exclusion & { templated: boolean }>();
for (const group of this.#groups.values()) {
@@ -0,0 +1,25 @@
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
/** Records and JSON-like records seen at one origin. */
export interface OriginStats {
records: number;
jsonLike: number;
}
/** The origin with the most JSON-like records, then most records, then lowest code points: the application a capture records. */
export const primaryOrigin = (
origins: ReadonlyMap<string, Readonly<OriginStats>>,
): string | null => {
let best: ({ origin: string } & OriginStats) | null = null;
for (const [origin, stats] of origins) {
if (
best === null ||
stats.jsonLike > best.jsonLike ||
(stats.jsonLike === best.jsonLike &&
(stats.records > best.records ||
(stats.records === best.records && compare(origin, best.origin) < 0)))
)
best = { origin, ...stats };
}
return best?.origin ?? null;
};
@@ -18,12 +18,14 @@ const PAGING_PARAMETERS = new Set([
"_end",
]);
/** Whether a query parameter pages or bounds a list rather than filtering it. */
export const isPagingParameter = (name: string): boolean =>
PAGING_PARAMETERS.has(name.toLowerCase());
/** Whether a query only pages its list: no parameters, or paging parameters alone. */
export const pagesOnly = (query: string | undefined): boolean =>
query === undefined ||
[...parseUrlQueryParameterNames(query)].every((name) =>
PAGING_PARAMETERS.has(name.toLowerCase()),
);
[...parseUrlQueryParameterNames(query)].every(isPagingParameter);
/** Paging parameters that bound how many items a page holds. */
const PAGE_SIZE_PARAMETERS = new Set([
@@ -0,0 +1,94 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { timedCaptureEvidence } from "../../fixtures/analysisView.js";
import { environmentStateServeFixture } from "../../fixtures/environmentServe.js";
import {
BOOK_NOT_FOUND,
BOOK_ROWS,
book,
} from "../../fixtures/environmentStateWrites.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
/** The books environment and a probe of the application re-seeded under other ids, written as Evidence files. */
const writeCompareFiles = async (root: string) => {
const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS));
const first = book(101, "First Book", "2026-10-20T09:00:00Z");
const second = book(102, "Second Book", "2026-10-20T10:00:00Z");
const probe = timedCaptureEvidence([
["GET", "/api/books", [first, second]],
["DELETE", "/api/books/102", "", 204],
["GET", "/api/books/102", BOOK_NOT_FOUND, 404],
]);
const paths = {
manifest: join(root, "manifest.json"),
capture: join(root, "capture.json"),
probe: join(root, "probe.json"),
};
await writeFile(paths.manifest, JSON.stringify(fixture.manifest));
await writeFile(paths.capture, JSON.stringify(fixture.capture));
await writeFile(paths.probe, JSON.stringify(probe));
return paths;
};
describe("rea environment-compare", () => {
cliTest(
"compares the served environment with a probe of the re-seeded application and prints the report",
async ({ cli }) => {
const files = await writeCompareFiles(
await createTestTempDirectory("rea-environment-compare-cli-"),
);
const result = await cli.run({
arguments: [
"environment-compare",
files.manifest,
"--evidence",
files.capture,
"--probe",
files.probe,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
status: "complete",
environment: { state: "served" },
summary: { probes: 1, steps: 3, equal: 3, agreement: 1 },
probes: [{ label: "probe.json", ids_mapped: 2 }],
});
},
);
cliTest(
"refuses a probe that is not capture Evidence and names it",
async ({ cli }) => {
const files = await writeCompareFiles(
await createTestTempDirectory("rea-environment-compare-refusal-cli-"),
);
const result = await cli.run({
arguments: [
"environment-compare",
files.manifest,
"--evidence",
files.capture,
"--probe",
files.manifest,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-compare",
issues: [{ path: ["probe", 0] }],
},
});
},
);
});