mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(environment): normalize values from owned response facts
Require a real clock-field context for numeric time normalization and actual rendered write rules for generated strings. Preserve literal read, replay and missing-response values, and retain generated aliases and distinctness within each response. Keep clock context separate from entity identity while traversing arrays. Add domain and public CLI controls for measurements, business strings, confirmed writes, missing responses and generated-value relationships, with documented inference limits. Fixes #6878 Fixes #6837
This commit is contained in:
@@ -771,8 +771,28 @@ recording replayed in its place and never from a recording whose body the probe
|
||||
two collections can reuse a live id, and an equal pair stays in that collection. A live id in a later request's path, query or body is translated before the
|
||||
environment answers, a path parameter by the collection the live path names (a nested collection keeps its parent values, each decoded once, and a literal route segment stays), 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
|
||||
`at`, `time`, `date`, `timestamp`, `created`, `updated` or `modified` field names, including snake-case and camel-case forms), 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.
|
||||
`at`, `time`, `date`, `timestamp`, `created`, `updated` or `modified` field names, including snake-case and camel-case forms).
|
||||
Numeric arrays retain that containing field's clock context through nested
|
||||
arrays; an object's own fields establish their own context. Numeric values at
|
||||
the body root, in root arrays, or under measurement fields remain literal.
|
||||
|
||||
Generated strings normalize only where this confirmed, served write's entity
|
||||
response rules identify generation. A `unique` rule or UUID identity rule must
|
||||
still compare two UUIDs; a `renewed` rule applies only at its recorded pointers,
|
||||
with `*` matching actual array items. Token/sequence strings retain the existing
|
||||
format check: equal length of at least eight, the same character classes, and
|
||||
a digit. Numeric sequences continue to compare literally unless an ID mapping
|
||||
applies. String shape and names such as `token` or `sku` alone do not establish
|
||||
generation. Read and replay responses without these facts stay literal except
|
||||
for supported clocks and learned IDs. A missing write target replays an absence
|
||||
response and does not inherit the successful entity response's generation rules.
|
||||
Admitted generated values must also preserve aliases and distinct identities
|
||||
within that response. UUID identity uses the generator's case-insensitive
|
||||
matching; token and sequence strings retain their literal identity. This
|
||||
correspondence ends with the answer and is not inferred across calls.
|
||||
The report counts each kind of normalised
|
||||
value; the generation rules remain model-derived facts, rather than new
|
||||
observations of the application.
|
||||
ISO clock offsets are accepted only within RFC 3339's `00`–`23` hour and `00`–`59` minute ranges.
|
||||
Probe ordering retains every recorded fractional-second digit in ISO timestamps
|
||||
with a four-digit year, `T`, and a `Z` or `±HH:MM` suffix. Equal instants use ordinal order. Other native-Date
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { decodedSegment } from "../../domain/analysisView/environmentStateRules.js";
|
||||
import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js";
|
||||
import { fromJsonSchema } from "@modelcontextprotocol/server";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
@@ -139,22 +140,37 @@ const entityOf = (
|
||||
run: ProbeRun,
|
||||
tool: EnvironmentManifestTool,
|
||||
segments: readonly string[],
|
||||
answer: ReplayResult,
|
||||
): {
|
||||
readonly scope: string;
|
||||
readonly at: string;
|
||||
readonly identity: boolean;
|
||||
readonly rules: readonly FieldRule[];
|
||||
} => {
|
||||
const view = run.store?.state.views.get(tool.name);
|
||||
const plan = run.store?.state.writes.get(tool.name);
|
||||
const response = plan?.response;
|
||||
const scope = collectionScope(tool, segments);
|
||||
if (view?.shape === "item" || view?.shape === "list")
|
||||
return { scope, at: view.pointer, identity: false };
|
||||
return { scope, at: view.pointer, identity: false, rules: [] };
|
||||
const identity = response?.shape === "identity";
|
||||
const at = identity
|
||||
? (response.pointer ?? "")
|
||||
: (response?.pointer ?? plan?.observation?.pointer ?? "");
|
||||
return { scope, at, identity };
|
||||
const provenance = (answer as Partial<StateAnswer>).state;
|
||||
const renderedWrite =
|
||||
provenance?.source === "state" &&
|
||||
(provenance.view === "create" ||
|
||||
provenance.view === "update" ||
|
||||
provenance.view === "document-write" ||
|
||||
provenance.view === "delete");
|
||||
// A missing target replays a recorded absence with state provenance too;
|
||||
// only an applied write can render the plan's entity response.
|
||||
const rules =
|
||||
plan?.served === true && response?.shape === "entity" && renderedWrite
|
||||
? plan.rules
|
||||
: [];
|
||||
return { scope, at, identity, rules };
|
||||
};
|
||||
|
||||
const fromState = (answer: ReplayResult): boolean =>
|
||||
@@ -350,7 +366,7 @@ const compareStep = (
|
||||
status: answered.value.status,
|
||||
body: envBody(run, answered.value),
|
||||
};
|
||||
const entity = entityOf(run, call.tool, liveSegments);
|
||||
const entity = entityOf(run, call.tool, liveSegments, answered.value);
|
||||
// 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(identityValue(live.body), identityValue(env.body), run.map, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js";
|
||||
import { compareAnswers } from "./environmentCompareDiff.js";
|
||||
import { IdMap, learnIds } from "./environmentCompareIds.js";
|
||||
|
||||
@@ -9,11 +10,13 @@ const compare = (
|
||||
env: unknown,
|
||||
map = new IdMap(),
|
||||
status: [number, number] = [200, 200],
|
||||
rules: readonly FieldRule[] = [],
|
||||
) =>
|
||||
compareAnswers(
|
||||
{ status: status[0], body: { kind: "json", value: live as JsonValue } },
|
||||
{ status: status[1], body: { kind: "json", value: env as JsonValue } },
|
||||
map,
|
||||
{ rules },
|
||||
);
|
||||
|
||||
describe("early-year clock formats", () => {
|
||||
@@ -32,7 +35,7 @@ describe("early-year clock formats", () => {
|
||||
});
|
||||
|
||||
describe("compareAnswers", () => {
|
||||
it("agrees on mapped ids, clocks of one format and generated values, and counts each", () => {
|
||||
it("agrees on mapped ids, clocks and confirmed generated values, and counts each", () => {
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1);
|
||||
const result = compare(
|
||||
@@ -49,6 +52,11 @@ describe("compareAnswers", () => {
|
||||
uuid: "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
|
||||
},
|
||||
map,
|
||||
[200, 200],
|
||||
[
|
||||
{ field: "token", rule: "identity", generator: "token" },
|
||||
{ field: "uuid", rule: "unique" },
|
||||
],
|
||||
);
|
||||
expect(result.differences).toEqual([]);
|
||||
expect(result.normalised).toEqual({
|
||||
@@ -105,7 +113,9 @@ describe("compareAnswers", () => {
|
||||
),
|
||||
).toEqual(["value"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapped fields and literal collections", () => {
|
||||
it("compares an object keyed by mapped ids under the environment's keys", () => {
|
||||
const map = new IdMap();
|
||||
map.learn("liveTable", "envTable");
|
||||
@@ -167,7 +177,7 @@ describe("compareAnswers", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a reordered set of scalars as order alone and reads epoch array items as clocks", () => {
|
||||
it("reports a reordered set of scalars as order alone and keeps nested numeric measurements literal", () => {
|
||||
expect(
|
||||
compare(
|
||||
{ access: ["a", "b", "c"] },
|
||||
@@ -177,7 +187,223 @@ describe("compareAnswers", () => {
|
||||
expect(
|
||||
compare({ stats: [[1792450800, 3]] }, { stats: [[1792285200, 3]] })
|
||||
.differences,
|
||||
).toEqual([]);
|
||||
).toEqual([
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/stats/0/0",
|
||||
live: "1792450800",
|
||||
env: "1792285200",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization context", () => {
|
||||
it.each([
|
||||
{ live: 1800000000, env: 1700000000, pointer: "" },
|
||||
{ live: [1800000000], env: [1700000000], pointer: "/0" },
|
||||
{
|
||||
live: { samples: [1800000000] },
|
||||
env: { samples: [1700000000] },
|
||||
pointer: "/samples/0",
|
||||
},
|
||||
{
|
||||
live: { updated_at: [{ samples: 1800000000 }] },
|
||||
env: { updated_at: [{ samples: 1700000000 }] },
|
||||
pointer: "/updated_at/0/samples",
|
||||
},
|
||||
])(
|
||||
"keeps epoch-shaped numbers literal at $pointer without a time field",
|
||||
({ live, env, pointer }) => {
|
||||
const result = compare(live, env);
|
||||
expect(result.differences).toEqual([
|
||||
{ category: "value", pointer, live: "1800000000", env: "1700000000" },
|
||||
]);
|
||||
expect(result.normalised.clocks).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the time field through nested numeric clock arrays", () => {
|
||||
const result = compare(
|
||||
{ updated_at: [[1700000000]] },
|
||||
{ updated_at: [[1800000000]] },
|
||||
);
|
||||
expect(result.differences).toEqual([]);
|
||||
expect(result.normalised.clocks).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["product1", "product2"],
|
||||
["product1", "productX"],
|
||||
["3f2a9c8d7e", "9b8e7d6c5a"],
|
||||
[
|
||||
"6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b",
|
||||
"0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
|
||||
],
|
||||
])(
|
||||
"keeps literal business strings %s versus %s without generation facts",
|
||||
(live, env) => {
|
||||
const result = compare(
|
||||
{ sku: live, items: [live] },
|
||||
{ sku: env, items: [env] },
|
||||
);
|
||||
expect(
|
||||
result.differences.map(({ category, pointer }) => [category, pointer]),
|
||||
).toEqual([
|
||||
["value", "/sku"],
|
||||
["value", "/items/0"],
|
||||
]);
|
||||
expect(result.normalised.generated).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("scopes confirmed renewal rules to their response entity and exact array pointers", () => {
|
||||
const first = "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b";
|
||||
const second = "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d";
|
||||
const answer = (token: string) => ({
|
||||
status: 200,
|
||||
body: {
|
||||
kind: "json" as const,
|
||||
value: {
|
||||
data: { values: [{ token, sku: token }] },
|
||||
values: [{ token }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = compareAnswers(answer(first), answer(second), new IdMap(), {
|
||||
at: "/data",
|
||||
rules: [{ field: "values", rule: "renewed", pointers: ["/*/token"] }],
|
||||
});
|
||||
expect(result.normalised.generated).toBe(1);
|
||||
expect(
|
||||
result.differences.map(({ category, pointer }) => [category, pointer]),
|
||||
).toEqual([
|
||||
["value", "/data/values/0/sku"],
|
||||
["value", "/values/0/token"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not extend generated-string normalization to numeric sequences", () => {
|
||||
const result = compare(
|
||||
{ code: 17 },
|
||||
{ code: 18 },
|
||||
new IdMap(),
|
||||
[200, 200],
|
||||
[{ field: "code", rule: "sequence", scope: "type" }],
|
||||
);
|
||||
expect(result.differences).toEqual([
|
||||
{ category: "value", pointer: "/code", live: "17", env: "18" },
|
||||
]);
|
||||
expect(result.normalised.generated).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated response identities", () => {
|
||||
const first = "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b";
|
||||
const second = "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d";
|
||||
const fresh = "123e4567-e89b-42d3-a456-426614174000";
|
||||
const other = "223e4567-e89b-42d3-a456-426614174000";
|
||||
const rules: readonly FieldRule[] = [
|
||||
{ field: "left", rule: "renewed", pointers: ["/*"] },
|
||||
{ field: "right", rule: "renewed", pointers: ["/*"] },
|
||||
];
|
||||
const answer = (left: string, right: string) => ({
|
||||
status: 200,
|
||||
body: { kind: "json" as const, value: { left: [left], right: [right] } },
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "consistent fresh renaming",
|
||||
liveLeft: fresh,
|
||||
liveRight: fresh,
|
||||
envLeft: first,
|
||||
envRight: first,
|
||||
differs: false,
|
||||
generated: 2,
|
||||
},
|
||||
{
|
||||
name: "case-equivalent UUID aliases",
|
||||
liveLeft: fresh.toUpperCase(),
|
||||
liveRight: fresh,
|
||||
envLeft: first,
|
||||
envRight: first.toUpperCase(),
|
||||
differs: false,
|
||||
generated: 2,
|
||||
},
|
||||
{
|
||||
name: "split alias",
|
||||
liveLeft: fresh,
|
||||
liveRight: other,
|
||||
envLeft: first,
|
||||
envRight: first,
|
||||
differs: true,
|
||||
generated: 1,
|
||||
},
|
||||
{
|
||||
name: "merged identities",
|
||||
liveLeft: fresh,
|
||||
liveRight: fresh,
|
||||
envLeft: first,
|
||||
envRight: second,
|
||||
differs: true,
|
||||
generated: 1,
|
||||
},
|
||||
{
|
||||
name: "split after an unchanged array",
|
||||
liveLeft: first,
|
||||
liveRight: fresh,
|
||||
envLeft: first,
|
||||
envRight: first,
|
||||
differs: true,
|
||||
generated: 0,
|
||||
},
|
||||
{
|
||||
name: "merge after an unchanged array",
|
||||
liveLeft: first,
|
||||
liveRight: first,
|
||||
envLeft: first,
|
||||
envRight: second,
|
||||
differs: true,
|
||||
generated: 0,
|
||||
},
|
||||
])(
|
||||
"preserves $name within one confirmed renewal response",
|
||||
({ liveLeft, liveRight, envLeft, envRight, differs, generated }) => {
|
||||
const result = compareAnswers(
|
||||
answer(liveLeft, liveRight),
|
||||
answer(envLeft, envRight),
|
||||
new IdMap(),
|
||||
{ rules },
|
||||
);
|
||||
expect(result.normalised.generated).toBe(generated);
|
||||
expect(result.differences).toEqual(
|
||||
differs
|
||||
? [
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/right/0",
|
||||
live: JSON.stringify(liveRight),
|
||||
env: JSON.stringify(envRight),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains order-only comparison for reordered generated scalar sets", () => {
|
||||
const result = compare(
|
||||
{ left: [first, second] },
|
||||
{ left: [second, first] },
|
||||
new IdMap(),
|
||||
[200, 200],
|
||||
rules,
|
||||
);
|
||||
expect(result.differences).toEqual([
|
||||
{ category: "order", pointer: "/left" },
|
||||
]);
|
||||
expect(result.normalised.generated).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,6 +542,32 @@ describe("identity responses two collections reuse", () => {
|
||||
});
|
||||
|
||||
describe("wrapped answers", () => {
|
||||
it("keeps collection identity context separate from a wrapped array's clock field context", () => {
|
||||
const map = new IdMap();
|
||||
map.learn(101, 1, "/api/books");
|
||||
map.learn(101, 2, "/api/authors");
|
||||
const result = compareAnswers(
|
||||
{
|
||||
status: 200,
|
||||
body: {
|
||||
kind: "json",
|
||||
value: { data: [{ id: 101, updated_at: [1700000000] }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
body: {
|
||||
kind: "json",
|
||||
value: { data: [{ id: 2, updated_at: [1800000000] }] },
|
||||
},
|
||||
},
|
||||
map,
|
||||
{ scope: "/api/authors", at: "/data" },
|
||||
);
|
||||
expect(result.differences).toEqual([]);
|
||||
expect(result.normalised).toMatchObject({ ids: 1, clocks: 1 });
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js";
|
||||
import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js";
|
||||
import {
|
||||
isUuid,
|
||||
readClock,
|
||||
@@ -74,16 +75,15 @@ const formatKey = (format: ClockFormat): string =>
|
||||
? `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. */
|
||||
/** The format of a clock value: an ISO-8601 string, or an epoch number under a time-named field. */
|
||||
const clockFormat = (
|
||||
value: JsonValue,
|
||||
key: string | undefined,
|
||||
): string | undefined => {
|
||||
if (
|
||||
typeof value === "number" &&
|
||||
key !== undefined &&
|
||||
!TIME_FIELD.test(key) &&
|
||||
!TIME_FIELD_CAMEL.test(key)
|
||||
(key === undefined ||
|
||||
(!TIME_FIELD.test(key) && !TIME_FIELD_CAMEL.test(key)))
|
||||
)
|
||||
return undefined;
|
||||
const clock = readClock(value);
|
||||
@@ -102,16 +102,42 @@ const characterClasses = (text: string): string =>
|
||||
.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 &&
|
||||
/** A confirmed response field's rule, and the pointer within that field (`*` marks actual array traversal). */
|
||||
interface Generation {
|
||||
readonly rule: FieldRule;
|
||||
readonly pointer: string;
|
||||
}
|
||||
|
||||
/** Whether a confirmed generation rule applies here and both strings retain its generated format. */
|
||||
const generatedPair = (
|
||||
live: string,
|
||||
env: string,
|
||||
generation: Generation | undefined,
|
||||
): boolean => {
|
||||
if (generation === undefined) return false;
|
||||
const { rule, pointer } = generation;
|
||||
if (rule.rule === "renewed")
|
||||
return rule.pointers.includes(pointer) && isUuid(live) && isUuid(env);
|
||||
if (pointer !== "") return false;
|
||||
if (
|
||||
rule.rule === "unique" ||
|
||||
(rule.rule === "identity" && rule.generator === "uuid")
|
||||
)
|
||||
return isUuid(live) && isUuid(env);
|
||||
if (rule.rule !== "sequence" && rule.rule !== "identity") return false;
|
||||
return (
|
||||
live.length === env.length &&
|
||||
live.length >= 8 &&
|
||||
/\d/u.test(live) &&
|
||||
/\d/u.test(env) &&
|
||||
characterClasses(live) === characterClasses(env));
|
||||
characterClasses(live) === characterClasses(env)
|
||||
);
|
||||
};
|
||||
|
||||
class Differ {
|
||||
readonly #rules: ReadonlyMap<string, FieldRule>;
|
||||
readonly #generatedToEnv = new Map<string, string>();
|
||||
readonly #generatedToLive = new Map<string, string>();
|
||||
readonly differences: Difference[] = [];
|
||||
readonly normalised: Normalised = {
|
||||
ids: 0,
|
||||
@@ -128,13 +154,39 @@ class Differ {
|
||||
readonly scope: string | undefined;
|
||||
readonly at: string;
|
||||
readonly identity: boolean;
|
||||
/** Parsed rules of the confirmed write whose entity response is being compared. */
|
||||
readonly rules: readonly FieldRule[];
|
||||
},
|
||||
) {}
|
||||
) {
|
||||
this.#rules = new Map(entity.rules.map((rule) => [rule.field, rule]));
|
||||
}
|
||||
|
||||
add(difference: Difference): void {
|
||||
this.differences.push(difference);
|
||||
}
|
||||
|
||||
/** One answer's generated identities must rename consistently in both directions. */
|
||||
#pairGenerated(live: string, env: string, rule: FieldRule): boolean {
|
||||
const uuid =
|
||||
rule.rule === "unique" ||
|
||||
rule.rule === "renewed" ||
|
||||
(rule.rule === "identity" && rule.generator === "uuid");
|
||||
const identity = (value: string) =>
|
||||
uuid ? `uuid:${value.toLowerCase()}` : `string:${value}`;
|
||||
const liveKey = identity(live);
|
||||
const envKey = identity(env);
|
||||
const knownEnv = this.#generatedToEnv.get(liveKey);
|
||||
const knownLive = this.#generatedToLive.get(envKey);
|
||||
if (
|
||||
(knownEnv !== undefined && knownEnv !== envKey) ||
|
||||
(knownLive !== undefined && knownLive !== liveKey)
|
||||
)
|
||||
return false;
|
||||
this.#generatedToEnv.set(liveKey, envKey);
|
||||
this.#generatedToLive.set(envKey, liveKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether a value compares within the call's collection, as an entity's own identity or a create's bare id does; other ids compare by the probe's mapping. */
|
||||
#inCollection(
|
||||
pointer: string,
|
||||
@@ -162,10 +214,29 @@ class Differ {
|
||||
live: JsonValue,
|
||||
env: JsonValue,
|
||||
pointer: string,
|
||||
at: { readonly key?: string; readonly own?: boolean } = {},
|
||||
at: {
|
||||
readonly key?: string;
|
||||
readonly own?: boolean;
|
||||
readonly clockKey?: string | undefined;
|
||||
} = {},
|
||||
generation?: Generation,
|
||||
): void {
|
||||
const key = at.clockKey ?? at.key;
|
||||
const liveClock = clockFormat(live, key);
|
||||
const envClock = clockFormat(env, key);
|
||||
const generatedMatch =
|
||||
generation !== undefined &&
|
||||
typeof live === "string" &&
|
||||
typeof env === "string" &&
|
||||
liveClock === undefined &&
|
||||
envClock === undefined &&
|
||||
!this.map.hasLive(live) &&
|
||||
!this.map.hasEnv(env) &&
|
||||
generatedPair(live, env, generation)
|
||||
? this.#pairGenerated(live, env, generation.rule)
|
||||
: undefined;
|
||||
if (live === env) {
|
||||
if (this.#reassigned(live, pointer, at))
|
||||
if (generatedMatch === false || this.#reassigned(live, pointer, at))
|
||||
this.add({
|
||||
category: "value",
|
||||
pointer,
|
||||
@@ -174,17 +245,17 @@ class Differ {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { key } = at;
|
||||
const field = at.key;
|
||||
if (
|
||||
(typeof live === "string" || typeof live === "number") &&
|
||||
(typeof env === "string" || typeof env === "number")
|
||||
) {
|
||||
const mapsAsId =
|
||||
(key !== undefined && ID_FIELD.test(key)) ||
|
||||
(field !== undefined && ID_FIELD.test(field)) ||
|
||||
this.#inCollection(pointer, at) ||
|
||||
(typeof live === "string" &&
|
||||
key !== undefined &&
|
||||
this.map.isStateReference(live, key));
|
||||
field !== undefined &&
|
||||
this.map.isStateReference(live, field));
|
||||
const mapped = mapsAsId
|
||||
? this.map.env(
|
||||
live,
|
||||
@@ -195,8 +266,6 @@ class Differ {
|
||||
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
|
||||
@@ -208,13 +277,7 @@ class Differ {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof live === "string" &&
|
||||
typeof env === "string" &&
|
||||
!this.map.hasLive(live) &&
|
||||
!this.map.hasEnv(env) &&
|
||||
generatedPair(live, env)
|
||||
) {
|
||||
if (generatedMatch === true) {
|
||||
this.normalised.generated += 1;
|
||||
return;
|
||||
}
|
||||
@@ -227,9 +290,28 @@ class Differ {
|
||||
});
|
||||
}
|
||||
|
||||
array(live: JsonValue[], env: JsonValue[], pointer: string): void {
|
||||
array(
|
||||
live: JsonValue[],
|
||||
env: JsonValue[],
|
||||
pointer: string,
|
||||
clockKey: string | undefined,
|
||||
generation: Generation | undefined,
|
||||
): void {
|
||||
const own = pointer === this.entity.at;
|
||||
if (this.sameItems(live, env, pointer)) return;
|
||||
if (this.sameItems(live, env, pointer)) {
|
||||
// Equal/reordered scalar sets still establish unchanged generated pairs;
|
||||
// otherwise another field could split an alias hidden by this fast path.
|
||||
if (generation !== undefined)
|
||||
for (const [index, item] of live.entries())
|
||||
this.scalar(
|
||||
item,
|
||||
item,
|
||||
`${pointer}/${String(index)}`,
|
||||
{ own, clockKey },
|
||||
{ ...generation, pointer: `${generation.pointer}/*` },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pairs = pairElements(live, env);
|
||||
if (live.length !== env.length || pairs.length !== live.length)
|
||||
this.add({
|
||||
@@ -244,9 +326,18 @@ class Differ {
|
||||
)
|
||||
this.add({ category: "order", pointer });
|
||||
for (const [left, right] of pairs)
|
||||
this.value(live[left]!, env[right]!, `${pointer}/${String(left)}`, {
|
||||
own,
|
||||
});
|
||||
this.value(
|
||||
live[left]!,
|
||||
env[right]!,
|
||||
`${pointer}/${String(left)}`,
|
||||
{
|
||||
own,
|
||||
clockKey,
|
||||
},
|
||||
generation === undefined
|
||||
? undefined
|
||||
: { ...generation, pointer: `${generation.pointer}/*` },
|
||||
);
|
||||
}
|
||||
|
||||
/** Arrays of scalars holding the same items in another order differ only in order, as sets an application returns unordered do. */
|
||||
@@ -282,6 +373,7 @@ class Differ {
|
||||
env: JsonObject,
|
||||
pointer: string,
|
||||
own = false,
|
||||
generation?: Generation,
|
||||
): void {
|
||||
const self = own || pointer === this.entity.at;
|
||||
// An object keyed by ids compares each member under the environment's key; any other compares field by field.
|
||||
@@ -306,11 +398,25 @@ class Differ {
|
||||
});
|
||||
else {
|
||||
matched.add(other);
|
||||
this.value(value, env[other]!, child, {
|
||||
key,
|
||||
own: self,
|
||||
keyed: self && keyed,
|
||||
});
|
||||
const rule = self ? this.#rules.get(key) : undefined;
|
||||
this.value(
|
||||
value,
|
||||
env[other]!,
|
||||
child,
|
||||
{
|
||||
key,
|
||||
own: self,
|
||||
keyed: self && keyed,
|
||||
},
|
||||
rule !== undefined
|
||||
? { rule, pointer: "" }
|
||||
: generation === undefined
|
||||
? undefined
|
||||
: {
|
||||
...generation,
|
||||
pointer: `${generation.pointer}/${escapePointer(key)}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(env))
|
||||
@@ -331,16 +437,19 @@ class Differ {
|
||||
readonly key?: string;
|
||||
readonly own?: boolean;
|
||||
readonly keyed?: boolean;
|
||||
readonly clockKey?: string | undefined;
|
||||
} = {},
|
||||
generation?: Generation,
|
||||
): void {
|
||||
if (Array.isArray(live) && Array.isArray(env))
|
||||
this.array(live, env, pointer);
|
||||
this.array(live, env, pointer, at.clockKey ?? at.key, generation);
|
||||
else if (isObject(live) && isObject(env))
|
||||
this.object(
|
||||
live,
|
||||
env,
|
||||
pointer,
|
||||
at.own === true && (at.key === undefined || at.keyed === true),
|
||||
generation,
|
||||
);
|
||||
else if (
|
||||
Array.isArray(live) ||
|
||||
@@ -354,7 +463,7 @@ class Differ {
|
||||
live: shown(live),
|
||||
env: shown(env),
|
||||
});
|
||||
else this.scalar(live, env, pointer, at);
|
||||
else this.scalar(live, env, pointer, at, generation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,12 +484,14 @@ export const compareAnswers = (
|
||||
readonly scope?: string;
|
||||
readonly at?: string;
|
||||
readonly identity?: boolean;
|
||||
readonly rules?: readonly FieldRule[];
|
||||
} = {},
|
||||
): { readonly differences: Difference[]; readonly normalised: Normalised } => {
|
||||
const differ = new Differ(map, {
|
||||
scope: entity.scope,
|
||||
at: entity.at ?? "",
|
||||
identity: entity.identity === true,
|
||||
rules: entity.rules ?? [],
|
||||
});
|
||||
if (live.status !== env.status)
|
||||
differ.add({
|
||||
|
||||
@@ -7,13 +7,18 @@ import {
|
||||
analysisViewCaptureEvidence,
|
||||
environmentManifestCaptureFixture,
|
||||
timedCaptureEvidence,
|
||||
type TimedCaptureRow,
|
||||
} from "../../fixtures/analysisView.js";
|
||||
import { environmentStateServeFixture } from "../../fixtures/environmentServe.js";
|
||||
import {
|
||||
BOOK_NOT_FOUND,
|
||||
BOOK_ROWS,
|
||||
FIRST_BOOK,
|
||||
SECOND_BOOK,
|
||||
THIRD_BOOK,
|
||||
book,
|
||||
numberedUuid,
|
||||
renamedBook,
|
||||
} from "../../fixtures/environmentStateWrites.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
@@ -98,7 +103,242 @@ const writeExactClockFiles = async (
|
||||
return originals;
|
||||
};
|
||||
|
||||
describe("rea environment-compare", () => {
|
||||
/** Capture-confirmed generated writes and literal/clock probes, retained as unchanged Evidence files. */
|
||||
const writeValueContextFiles = async (
|
||||
root: string,
|
||||
): Promise<Map<string, string>> => {
|
||||
const recorded = {
|
||||
samples: [1700000000],
|
||||
nested_samples: [[1700000000]],
|
||||
scalar_samples: 1700000000,
|
||||
sku: "product1",
|
||||
catalog: ["product1"],
|
||||
serial: numberedUuid(1),
|
||||
updated_at: [1700000000],
|
||||
nested: { updated_at: 1700000000 },
|
||||
fixed_samples: [1700000000],
|
||||
};
|
||||
const clocks = {
|
||||
...recorded,
|
||||
updated_at: [1800000000],
|
||||
nested: { updated_at: 1800000000 },
|
||||
};
|
||||
const changed = {
|
||||
...clocks,
|
||||
samples: [1800000000],
|
||||
nested_samples: [[1800000000]],
|
||||
scalar_samples: 1800000000,
|
||||
sku: "product2",
|
||||
catalog: ["product2"],
|
||||
serial: numberedUuid(2),
|
||||
};
|
||||
const first = { ...FIRST_BOOK, ref: numberedUuid(1) };
|
||||
const second = { ...SECOND_BOOK, ref: numberedUuid(2) };
|
||||
const third = { ...THIRD_BOOK, ref: numberedUuid(3) };
|
||||
const renamed = {
|
||||
...renamedBook(first, "Renamed Book", 6),
|
||||
ref: numberedUuid(4),
|
||||
};
|
||||
const missing = { error: "missing", ref: numberedUuid(99) };
|
||||
const rows: TimedCaptureRow[] = [
|
||||
["GET", "/api/config", recorded],
|
||||
["GET", "/api/books", [first, second]],
|
||||
["GET", "/api/books/1", first],
|
||||
["GET", "/api/books/2", second],
|
||||
["POST", "/api/books", third, 201, { name: "Third Book" }],
|
||||
["GET", "/api/books/3", third],
|
||||
["PATCH", "/api/books/1", renamed, 200, { name: "Renamed Book" }],
|
||||
["GET", "/api/books/1", renamed],
|
||||
["GET", "/api/books/99", missing, 404],
|
||||
];
|
||||
const capture = timedCaptureEvidence(rows);
|
||||
const fixture = environmentStateServeFixture(capture);
|
||||
expect(fixture.state.normalized_result).toMatchObject({
|
||||
writes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tool: "post_books",
|
||||
served: true,
|
||||
response: { ordinal: 4, shape: "entity", pointer: "" },
|
||||
rules: expect.arrayContaining([{ field: "ref", rule: "unique" }]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tool: "patch_books_by_id",
|
||||
served: true,
|
||||
response: { ordinal: 6, shape: "entity", pointer: "" },
|
||||
rules: expect.arrayContaining([{ field: "ref", rule: "unique" }]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const generated = {
|
||||
...book(101, "Third Book", "2026-11-01T12:00:00Z"),
|
||||
ref: numberedUuid(101),
|
||||
};
|
||||
const originals = new Map<string, string>();
|
||||
for (const [name, value] of [
|
||||
["manifest.json", fixture.manifest],
|
||||
["capture.json", capture],
|
||||
["changed.json", timedCaptureEvidence([["GET", "/api/config", changed]])],
|
||||
["clocks.json", timedCaptureEvidence([["GET", "/api/config", clocks]])],
|
||||
[
|
||||
"generated.json",
|
||||
timedCaptureEvidence([
|
||||
["POST", "/api/books", generated, 201, { name: "Third Book" }],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/books/1",
|
||||
{ ...renamed, ref: numberedUuid(102) },
|
||||
200,
|
||||
{ name: "Renamed Book" },
|
||||
],
|
||||
]),
|
||||
],
|
||||
[
|
||||
"missing.json",
|
||||
timedCaptureEvidence([
|
||||
[
|
||||
"PATCH",
|
||||
"/api/books/99",
|
||||
{ ...missing, ref: numberedUuid(100) },
|
||||
404,
|
||||
{ name: "Ghost" },
|
||||
],
|
||||
]),
|
||||
],
|
||||
] as const) {
|
||||
const path = join(root, name);
|
||||
const text = JSON.stringify(value);
|
||||
originals.set(path, text);
|
||||
await writeFile(path, text);
|
||||
}
|
||||
return originals;
|
||||
};
|
||||
|
||||
describe("rea environment-compare value context", () => {
|
||||
cliTest(
|
||||
"keeps business values and numeric measurements literal while normalizing confirmed generation and clocks",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-environment-value-context-",
|
||||
);
|
||||
const originals = await writeValueContextFiles(root);
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"environment-compare",
|
||||
join(root, "manifest.json"),
|
||||
"--evidence",
|
||||
join(root, "capture.json"),
|
||||
"--probe",
|
||||
join(root, "changed.json"),
|
||||
"--probe",
|
||||
join(root, "clocks.json"),
|
||||
"--probe",
|
||||
join(root, "generated.json"),
|
||||
"--probe",
|
||||
join(root, "missing.json"),
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
status: "complete",
|
||||
summary: {
|
||||
probes: 4,
|
||||
steps: 5,
|
||||
equal: 3,
|
||||
agreement: 0.6,
|
||||
normalised: { generated: 2 },
|
||||
},
|
||||
probes: [
|
||||
{
|
||||
label: "changed.json",
|
||||
normalised: { generated: 0, clocks: 2, ids: 0 },
|
||||
results: [
|
||||
{
|
||||
equal: false,
|
||||
categories: ["value"],
|
||||
differences: [
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/samples/0",
|
||||
live: "1800000000",
|
||||
env: "1700000000",
|
||||
},
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/nested_samples/0/0",
|
||||
live: "1800000000",
|
||||
env: "1700000000",
|
||||
},
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/scalar_samples",
|
||||
live: "1800000000",
|
||||
env: "1700000000",
|
||||
},
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/sku",
|
||||
live: '"product2"',
|
||||
env: '"product1"',
|
||||
},
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/catalog/0",
|
||||
live: '"product2"',
|
||||
env: '"product1"',
|
||||
},
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/serial",
|
||||
live: JSON.stringify(numberedUuid(2)),
|
||||
env: JSON.stringify(numberedUuid(1)),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "clocks.json",
|
||||
normalised: { clocks: 2, generated: 0 },
|
||||
results: [{ equal: true, differences: [] }],
|
||||
},
|
||||
{
|
||||
label: "generated.json",
|
||||
normalised: { generated: 2, ids: 1 },
|
||||
results: [
|
||||
{ equal: true, differences: [] },
|
||||
{ equal: true, differences: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "missing.json",
|
||||
normalised: { generated: 0 },
|
||||
results: [
|
||||
{
|
||||
tool: "patch_books_by_id",
|
||||
live_status: 404,
|
||||
env_status: 404,
|
||||
equal: false,
|
||||
differences: [
|
||||
{
|
||||
category: "value",
|
||||
pointer: "/ref",
|
||||
live: JSON.stringify(numberedUuid(100)),
|
||||
env: JSON.stringify(numberedUuid(99)),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const [path, text] of originals)
|
||||
expect(await readFile(path, "utf8")).toBe(text);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("rea environment-compare replay and chronology", () => {
|
||||
cliTest(
|
||||
"preserves exact probe chronology and first-century clock-format differences",
|
||||
async ({ cli }) => {
|
||||
|
||||
Reference in New Issue
Block a user