fix(browser): compare script and tool facts without metadata identity churn

This commit is contained in:
morluto
2026-10-24 02:47:03 +08:00
parent 9038589e76
commit 65e423eba6
4 changed files with 156 additions and 63 deletions
+9
View File
@@ -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.
+94 -62
View File
@@ -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" }),
]);
},
);