mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): compare script and tool facts without metadata identity churn
This commit is contained in:
@@ -95,6 +95,15 @@ objects, unsupported values, and opaque cookie partition identities remain
|
||||
incomplete. Previously exported fingerprints are not recomputed; recapture both
|
||||
sides when comparing captures produced across this decoder change.
|
||||
|
||||
Script comparison groups observations by reported URL and compares unordered
|
||||
content samples. Producer inventory keys include optional metadata, so a changed
|
||||
key cannot by itself prove script removal or addition. Missing module, length,
|
||||
language or source-hash facts remain unknown; shared known values and complete
|
||||
retained source digests can still prove a group change. Partial inventories use
|
||||
the same omission rule as repeated HTTP samples. WebMCP dropped-tool counts also
|
||||
prevent equality, and two incomplete owner inventories cannot prove tool
|
||||
correspondence merely from their different retained registrations.
|
||||
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG.
|
||||
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
||||
- The CLI comparison commands accept each capture, screenshot artifact or normalization policy as inline JSON or a JSON file path. Use files for screenshot artifacts and scenario captures, whose embedded PNG bytes can exceed the host's command-line length limit.
|
||||
|
||||
@@ -283,14 +283,6 @@ const sameResourceSizes = (
|
||||
): boolean =>
|
||||
left.size === right.size && [...left].every((size) => right.has(size));
|
||||
|
||||
const keyed = <T>(
|
||||
values: readonly T[],
|
||||
identity: (value: T) => string,
|
||||
): ReadonlyMap<string, string> =>
|
||||
new Map(
|
||||
values.map((value) => [identity(value), digestCanonicalValue(value)]),
|
||||
);
|
||||
|
||||
type BodyShapeSources = {
|
||||
readonly request: boolean;
|
||||
readonly response: boolean;
|
||||
@@ -676,8 +668,8 @@ const networkComparisonMaps = (
|
||||
const next = after.get(identity) ?? [];
|
||||
if (
|
||||
!(beforeComplete && afterComplete) &&
|
||||
!(beforeComplete && !networkSamplesContained(next, previous)) &&
|
||||
!(afterComplete && !networkSamplesContained(previous, next))
|
||||
!(beforeComplete && !samplesContained(next, previous)) &&
|
||||
!(afterComplete && !samplesContained(previous, next))
|
||||
)
|
||||
continue;
|
||||
if (before.has(identity))
|
||||
@@ -688,7 +680,7 @@ const networkComparisonMaps = (
|
||||
return [left, right];
|
||||
};
|
||||
|
||||
const networkSamplesContained = (
|
||||
const samplesContained = (
|
||||
samples: readonly string[],
|
||||
complete: readonly string[],
|
||||
): boolean => {
|
||||
@@ -842,20 +834,30 @@ const webMcpDimension = (
|
||||
};
|
||||
const previous = groups(before);
|
||||
const next = groups(after);
|
||||
const inventoryComplete = webMcpComplete(before) && webMcpComplete(after);
|
||||
const beforeComplete = webMcpComplete(before);
|
||||
const afterComplete = webMcpComplete(after);
|
||||
const inventoryComplete = beforeComplete && afterComplete;
|
||||
const left = new Map<string, string>();
|
||||
const right = new Map<string, string>();
|
||||
let valuesComplete = true;
|
||||
for (const identity of new Set([...previous.keys(), ...next.keys()])) {
|
||||
const beforeTools = previous.get(identity) ?? [];
|
||||
const afterTools = next.get(identity) ?? [];
|
||||
if (!beforeComplete && !afterComplete) {
|
||||
valuesComplete = false;
|
||||
continue;
|
||||
}
|
||||
// Multiple same-name owners at the same URL cannot be paired by frame ID.
|
||||
// Keep this uncertainty rather than selecting a duplicate by capture order.
|
||||
if (beforeTools.length > 1 || afterTools.length > 1) {
|
||||
valuesComplete = false;
|
||||
// Complete counts can contradict one another even when individual
|
||||
// owners cannot be paired. Do not discard that independent proof.
|
||||
if (inventoryComplete) {
|
||||
if (
|
||||
inventoryComplete ||
|
||||
(beforeComplete && afterTools.length > beforeTools.length) ||
|
||||
(afterComplete && beforeTools.length > afterTools.length)
|
||||
) {
|
||||
if (beforeTools.length > 0)
|
||||
left.set(identity, String(beforeTools.length));
|
||||
if (afterTools.length > 0)
|
||||
@@ -898,13 +900,19 @@ const webMcpDimension = (
|
||||
right,
|
||||
inventoryComplete && valuesComplete,
|
||||
"WebMCP discovery, owner correspondence, or consequential annotation evidence was unavailable or incomplete in at least one capture.",
|
||||
inventoryComplete,
|
||||
(identity) =>
|
||||
!previous.has(identity)
|
||||
? beforeComplete
|
||||
: !next.has(identity)
|
||||
? afterComplete
|
||||
: inventoryComplete,
|
||||
);
|
||||
};
|
||||
|
||||
const webMcpComplete = (discovery: WebMcpDiscovery | null): boolean =>
|
||||
discovery !== null &&
|
||||
discovery.status === "available" &&
|
||||
discovery.completeness.dropped_events.webmcp_tools === 0 &&
|
||||
!incompleteSections(discovery.completeness).has("webmcp_tools");
|
||||
|
||||
const sectionsComplete = (
|
||||
@@ -987,62 +995,86 @@ const domDimension = (
|
||||
};
|
||||
};
|
||||
|
||||
const scriptProjection = (
|
||||
script: WebPageInspection["scripts"]["items"][number],
|
||||
) => ({
|
||||
script_key: script.script_key,
|
||||
url: script.url,
|
||||
cdp_hash: script.cdp_hash,
|
||||
length: script.length,
|
||||
is_module: script.is_module,
|
||||
language: script.language,
|
||||
source_map_url: script.source_map_url,
|
||||
});
|
||||
|
||||
const scriptsDimension = (
|
||||
before: WebPageInspection,
|
||||
after: WebPageInspection,
|
||||
): Dimension => {
|
||||
const inventoryComplete = [before, after].every((inspection) =>
|
||||
sectionsComplete(inspection, ["scripts"]),
|
||||
);
|
||||
const byKey = (inspection: WebPageInspection) =>
|
||||
new Map(
|
||||
inspection.scripts.items.map((script) => [script.script_key, script]),
|
||||
);
|
||||
const left = byKey(before);
|
||||
const right = byKey(after);
|
||||
let sourcesComplete = true;
|
||||
const project = (inspection: WebPageInspection, peer: typeof left) =>
|
||||
keyed(
|
||||
inspection.scripts.items.map((script) => {
|
||||
const other = peer.get(script.script_key);
|
||||
const sourceComparable =
|
||||
script.source.included && other?.source.included === true;
|
||||
if (
|
||||
other !== undefined &&
|
||||
(((script.source.included || other.source.included) &&
|
||||
!sourceComparable) ||
|
||||
(script.cdp_hash === null && !sourceComparable))
|
||||
)
|
||||
sourcesComplete = false;
|
||||
return {
|
||||
...scriptProjection(script),
|
||||
type Script = WebPageInspection["scripts"]["items"][number];
|
||||
const groups = (inspection: WebPageInspection) => {
|
||||
const result = new Map<string, Script[]>();
|
||||
for (const script of inspection.scripts.items) {
|
||||
// Inventory keys include optional metadata. Their spelling cannot prove
|
||||
// semantic identity when that metadata is missing in another capture.
|
||||
const identity = `scrg_${digestCanonicalValue(script.url)}`;
|
||||
const items = result.get(identity) ?? [];
|
||||
items.push(script);
|
||||
result.set(identity, items);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const previous = groups(before);
|
||||
const next = groups(after);
|
||||
const beforeComplete =
|
||||
sectionsComplete(before, ["scripts"]) &&
|
||||
before.completeness.dropped_events.scripts === 0;
|
||||
const afterComplete =
|
||||
sectionsComplete(after, ["scripts"]) &&
|
||||
after.completeness.dropped_events.scripts === 0;
|
||||
const left = new Map<string, string>();
|
||||
const right = new Map<string, string>();
|
||||
let valuesComplete = true;
|
||||
for (const identity of new Set([...previous.keys(), ...next.keys()])) {
|
||||
const prior = previous.get(identity) ?? [];
|
||||
const later = next.get(identity) ?? [];
|
||||
const all = [...prior, ...later];
|
||||
const fields = (
|
||||
["cdp_hash", "length", "is_module", "language"] as const
|
||||
).filter((field) => all.every((script) => script[field] !== null));
|
||||
const sourceComparable =
|
||||
prior.length > 0 &&
|
||||
later.length > 0 &&
|
||||
all.every((script) => script.source.included);
|
||||
if (
|
||||
prior.length > 0 &&
|
||||
later.length > 0 &&
|
||||
(!fields.includes("length") ||
|
||||
!fields.includes("is_module") ||
|
||||
!fields.includes("language") ||
|
||||
(!sourceComparable &&
|
||||
(!fields.includes("cdp_hash") ||
|
||||
all.some((script) => script.source.included))))
|
||||
)
|
||||
valuesComplete = false;
|
||||
const project = (items: readonly Script[]) =>
|
||||
items.map((script) =>
|
||||
digestCanonicalValue({
|
||||
url: script.url,
|
||||
source_map_url: script.source_map_url,
|
||||
...Object.fromEntries(fields.map((field) => [field, script[field]])),
|
||||
...(sourceComparable && script.source.included
|
||||
? { source_sha256: script.source.artifact.sha256 }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
(script) => script.script_key,
|
||||
);
|
||||
const previous = project(before, right);
|
||||
const next = project(after, left);
|
||||
}),
|
||||
);
|
||||
const priorSamples = project(prior);
|
||||
const laterSamples = project(later);
|
||||
if (
|
||||
!(beforeComplete && afterComplete) &&
|
||||
!(beforeComplete && !samplesContained(laterSamples, priorSamples)) &&
|
||||
!(afterComplete && !samplesContained(priorSamples, laterSamples))
|
||||
)
|
||||
continue;
|
||||
if (prior.length > 0)
|
||||
left.set(identity, digestCanonicalValue(priorSamples.sort()));
|
||||
if (later.length > 0)
|
||||
right.set(identity, digestCanonicalValue(laterSamples.sort()));
|
||||
}
|
||||
return compareDimension(
|
||||
previous,
|
||||
next,
|
||||
inventoryComplete && sourcesComplete,
|
||||
"Script inventory or corresponding source digest evidence was incomplete in at least one observation.",
|
||||
inventoryComplete,
|
||||
left,
|
||||
right,
|
||||
beforeComplete && afterComplete && valuesComplete,
|
||||
"Script inventory or corresponding source/metadata evidence was incomplete in at least one observation.",
|
||||
true, // Selected map entries already carry their directional absence proof.
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect } from "vitest";
|
||||
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createWebTextArtifact } from "../../../src/domain/webContentArtifact.js";
|
||||
import { stableWebScriptKey } from "../../../src/domain/webInventory.js";
|
||||
import type { WebMcpDiscovery } from "../../../src/domain/webMcpDiscovery.js";
|
||||
import {
|
||||
type CompareWebCapturesInput,
|
||||
@@ -250,6 +251,33 @@ add(
|
||||
"webmcp",
|
||||
"changed",
|
||||
);
|
||||
const droppedTools = structuredClone(discovery);
|
||||
droppedTools.completeness = withPassiveDroppedEvents(inspection, {
|
||||
webmcp_tools: 1,
|
||||
}).completeness;
|
||||
const partialChangedTools = structuredClone(changedTool);
|
||||
partialChangedTools.completeness = structuredClone(droppedTools.completeness);
|
||||
add(
|
||||
"dropped WebMCP observations cannot establish equality",
|
||||
{ inspection, webmcp: droppedTools },
|
||||
{ inspection, webmcp: droppedTools },
|
||||
"webmcp",
|
||||
"unknown",
|
||||
);
|
||||
add(
|
||||
"two partial WebMCP groups cannot prove modification",
|
||||
{ inspection, webmcp: droppedTools },
|
||||
{ inspection, webmcp: partialChangedTools },
|
||||
"webmcp",
|
||||
"unknown",
|
||||
);
|
||||
add(
|
||||
"known WebMCP difference survives one complete inventory",
|
||||
{ inspection, webmcp: discovery },
|
||||
{ inspection, webmcp: partialChangedTools },
|
||||
"webmcp",
|
||||
"changed",
|
||||
);
|
||||
const duplicateOwner = structuredClone(discovery);
|
||||
duplicateOwner.tools.total = 2;
|
||||
duplicateOwner.tools.items.push({
|
||||
@@ -304,6 +332,30 @@ script.scripts = {
|
||||
],
|
||||
};
|
||||
const scriptChanged = structuredClone(script);
|
||||
const scriptMissingModule = structuredClone(script);
|
||||
scriptMissingModule.scripts.items[0]!.is_module = null;
|
||||
scriptMissingModule.scripts.items[0]!.script_key = stableWebScriptKey(
|
||||
scriptMissingModule.scripts.items[0]!,
|
||||
);
|
||||
add(
|
||||
"missing script module metadata is unknown rather than removed and added",
|
||||
{ inspection: script },
|
||||
{ inspection: scriptMissingModule },
|
||||
"scripts",
|
||||
"unknown",
|
||||
);
|
||||
const scriptDifferentModule = structuredClone(script);
|
||||
scriptDifferentModule.scripts.items[0]!.is_module = true;
|
||||
scriptDifferentModule.scripts.items[0]!.script_key = stableWebScriptKey(
|
||||
scriptDifferentModule.scripts.items[0]!,
|
||||
);
|
||||
add(
|
||||
"known unequal script module metadata remains changed",
|
||||
{ inspection: script },
|
||||
{ inspection: scriptDifferentModule },
|
||||
"scripts",
|
||||
"changed",
|
||||
);
|
||||
scriptChanged.scripts.items[0]!.source = {
|
||||
included: true,
|
||||
artifact: createWebTextArtifact("2", "text/javascript"),
|
||||
|
||||
@@ -1007,7 +1007,7 @@ describe("web capture diff incomplete inventories", () => {
|
||||
if (script === undefined) throw new Error("Missing captured script");
|
||||
script.url = `${script.url}?revision=2`;
|
||||
expect(compare(complete, modified).changes).toEqual([
|
||||
expect.objectContaining({ change: "modified" }),
|
||||
expect.objectContaining({ change: "added" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user