From 65e423eba6516550bda7463ba2111c3e9d9b3ea7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:47:03 +0800 Subject: [PATCH] fix(browser): compare script and tool facts without metadata identity churn --- docs/browser-observation.md | 9 + src/domain/webCaptureDiff.ts | 156 +++++++++++------- .../passiveCaptureCorrespondence.test.ts | 52 ++++++ tests/boundary/browser/webCaptureDiff.test.ts | 2 +- 4 files changed, 156 insertions(+), 63 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 6c2c04a02a..099801c6fb 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -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. diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 1e8138b384..b43c4c4160 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -283,14 +283,6 @@ const sameResourceSizes = ( ): boolean => left.size === right.size && [...left].every((size) => right.has(size)); -const keyed = ( - values: readonly T[], - identity: (value: T) => string, -): ReadonlyMap => - 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(); const right = new Map(); 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(); + 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(); + const right = new Map(); + 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. ); }; diff --git a/tests/acceptance/tools/passiveCaptureCorrespondence.test.ts b/tests/acceptance/tools/passiveCaptureCorrespondence.test.ts index 521bc7d564..707129d888 100644 --- a/tests/acceptance/tools/passiveCaptureCorrespondence.test.ts +++ b/tests/acceptance/tools/passiveCaptureCorrespondence.test.ts @@ -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"), diff --git a/tests/boundary/browser/webCaptureDiff.test.ts b/tests/boundary/browser/webCaptureDiff.test.ts index 851cc25874..f847c3bf7c 100644 --- a/tests/boundary/browser/webCaptureDiff.test.ts +++ b/tests/boundary/browser/webCaptureDiff.test.ts @@ -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" }), ]); }, );