From d7651b6a0dde6cfe658144cc52794ae6aaf23306 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:04:49 +0800 Subject: [PATCH] fix(javascript): preserve mapped runtime digest disagreements Fixes #3316 Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/javascript-runtime-reconciliation.md | 11 +- .../javascript/javascriptRuntimeLoadState.ts | 13 +- ...javascriptRuntimeReconciliationMatching.ts | 122 +++++---- .../javascriptRuntimeContentPriority.test.ts | 231 ++++++++++++++++++ .../javascriptRuntimeReconciliation.test.ts | 23 -- 5 files changed, 316 insertions(+), 84 deletions(-) create mode 100644 tests/boundary/filesystem/javascript-applications/javascriptRuntimeContentPriority.test.ts diff --git a/docs/javascript-runtime-reconciliation.md b/docs/javascript-runtime-reconciliation.md index a0a6005745..7efbfc3e2d 100644 --- a/docs/javascript-runtime-reconciliation.md +++ b/docs/javascript-runtime-reconciliation.md @@ -91,17 +91,20 @@ nodes. REA evaluates compatible static candidates conservatively: 1. A captured-source SHA-256 plus one matching mapped location yields a content-and-location match. -2. One unique exact content digest can match even when no location mapping is - available. Whole-asset digests and reconstructed module-factory source +2. When no static candidate exists at a mapped location, one unique exact + content digest can match. Whole-asset digests and reconstructed module-factory source digests remain distinguishable in the reported basis. 3. Without captured bytes, one unique mapped artifact path can produce a medium-confidence location match. 4. Multiple candidates remain `ambiguous`; no `observed_as` edge is emitted. + With captured source and mapped candidates, only mapped content matches + qualify; unrelated content matches elsewhere are excluded. `candidate_static_count` reports the full qualified layer/node count, and `candidate_static_nodes` includes every unique candidate reference. 5. If captured bytes disagree with the static candidate at the mapped path, - the result is an explicit mismatch. A path never overrides a digest - disagreement. + the result is an explicit mismatch. An equal digest elsewhere cannot hide + that disagreement. If a mapped candidate's digest is unavailable and no + mapped content match is known, the comparison remains `unknown`. 6. Missing authorization mappings and incomplete or truncated input coverage remain unknown or truncated rather than becoming absence claims. diff --git a/src/domain/javascript/javascriptRuntimeLoadState.ts b/src/domain/javascript/javascriptRuntimeLoadState.ts index d2d2cab43a..a1d064efcb 100644 --- a/src/domain/javascript/javascriptRuntimeLoadState.ts +++ b/src/domain/javascript/javascriptRuntimeLoadState.ts @@ -47,6 +47,7 @@ const classifyLayer = ( const nodes = layer.graph.nodes.filter(isLoadStateNode); const direct = new Map(); const ambiguous = new Set(); + const incomplete = new Set(); for (const match of matches) if ( match.status === "matched" && @@ -54,10 +55,12 @@ const classifyLayer = ( match.static_node_id !== null ) append(direct, match.static_node_id, match.runtime_node_id); - else if (match.status === "ambiguous") + else if (match.status === "ambiguous" || match.status === "unknown") { + const uncertain = match.status === "ambiguous" ? ambiguous : incomplete; for (const candidate of match.candidate_static_nodes) if (candidate.static_layer_id === layer.layerId) - ambiguous.add(candidate.static_node_id); + uncertain.add(candidate.static_node_id); + } const resident = residentNodes(layer, new Set(direct.keys())); const runtimeScope = createStaticRuntimeScope(layer, runtimeEntities); const scopedEntities = runtimeEntities.filter( @@ -82,11 +85,13 @@ const classifyLayer = ( runtimeNodeIds: [], reason: "containing-asset-was-loaded", }); - if (ambiguous.has(node.node_id)) + if (incomplete.has(node.node_id) || ambiguous.has(node.node_id)) return state(layer, node, { status: "unknown", runtimeNodeIds: [], - reason: "ambiguous-runtime-candidate", + reason: incomplete.has(node.node_id) + ? "static-or-runtime-coverage-incomplete" + : "ambiguous-runtime-candidate", }); const nodeWithinScope = staticNodeWithinRuntimeScope(node, runtimeScope); if (layerComplete && nodeWithinScope) diff --git a/src/domain/javascript/javascriptRuntimeReconciliationMatching.ts b/src/domain/javascript/javascriptRuntimeReconciliationMatching.ts index 0c3d6e80b1..5e6ca4cd1e 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationMatching.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationMatching.ts @@ -78,60 +78,13 @@ const reconcileEntity = ( candidates: [], }); const locations = candidatesWithLocation(entity, index, layers); - if (entity.sourceSha256 !== null) { - const content = - index.byCategoryDigest.get( - categoryDigestKey(entity.staticKind, entity.sourceSha256), - ) ?? []; - const locatedKeys = new Set( - locations.map(({ candidate }) => candidateKey(candidate)), + if (entity.sourceSha256 !== null) + return reconcileCapturedSource( + entity, + entity.sourceSha256, + locations, + index, ); - const combined = content.filter((candidate) => - locatedKeys.has(candidateKey(candidate)), - ); - const combinedCandidate = combined.at(0); - if (combined.length === 1 && combinedCandidate !== undefined) - return matched(entity, combinedCandidate, { - basis: "content-and-location", - confidence: "high", - reason: "content-and-location-match", - }); - const contentCandidate = content.at(0); - if (content.length === 1 && contentCandidate !== undefined) { - const candidate = contentCandidate; - const digestBasis = candidate.digests.find( - ({ sha256 }) => sha256 === entity.sourceSha256, - )?.basis; - return matched(entity, candidate, { - basis: digestBasis ?? "content-sha256", - confidence: "high", - reason: "unique-content-match", - }); - } - if (content.length > 1) - return unresolved(entity, { - status: "ambiguous", - basis: "none", - confidence: "unknown", - reason: "ambiguous-static-candidates", - candidates: content, - }); - if (locations.length > 0) - return unresolved(entity, { - status: "unmatched", - basis: strongestLocationBasis(locations), - confidence: "high", - reason: "captured-content-disagrees-with-static-location", - candidates: locations.map(({ candidate }) => candidate), - }); - return unresolved(entity, { - status: "unmatched", - basis: "none", - confidence: "high", - reason: "no-static-content-match", - candidates: [], - }); - } const location = locations.at(0); if (locations.length === 1 && location !== undefined) { @@ -169,6 +122,69 @@ interface LocatedCandidate { readonly mapping: MappedRuntimePath; } +const reconcileCapturedSource = ( + entity: RuntimeReconciliationEntity, + sourceSha256: string, + locations: readonly LocatedCandidate[], + index: CandidateIndex, +): EvaluatedMatch => { + // A known mapped candidate owns the comparison. Unrelated equal bytes cannot + // repair a disagreement or establish the content of a candidate without a digest. + const content = + locations.length > 0 + ? locations + .map(({ candidate }) => candidate) + .filter(({ digests }) => + digests.some(({ sha256 }) => sha256 === sourceSha256), + ) + : (index.byCategoryDigest.get( + categoryDigestKey(entity.staticKind, sourceSha256), + ) ?? []); + const candidate = content.at(0); + if (content.length === 1 && candidate !== undefined) + return matched(entity, candidate, { + basis: + locations.length > 0 + ? "content-and-location" + : (candidate.digests.find(({ sha256 }) => sha256 === sourceSha256) + ?.basis ?? "content-sha256"), + confidence: "high", + reason: + locations.length > 0 + ? "content-and-location-match" + : "unique-content-match", + }); + if (content.length > 1) + return unresolved(entity, { + status: "ambiguous", + basis: locations.length > 0 ? "content-and-location" : "none", + confidence: "unknown", + reason: "ambiguous-static-candidates", + candidates: content, + }); + if (locations.length > 0) { + const digestUnavailable = locations.some( + ({ candidate: { digests } }) => digests.length === 0, + ); + return unresolved(entity, { + status: digestUnavailable ? "unknown" : "unmatched", + basis: strongestLocationBasis(locations), + confidence: digestUnavailable ? "unknown" : "high", + reason: digestUnavailable + ? "static-or-runtime-coverage-incomplete" + : "captured-content-disagrees-with-static-location", + candidates: locations.map(({ candidate: located }) => located), + }); + } + return unresolved(entity, { + status: "unmatched", + basis: "none", + confidence: "high", + reason: "no-static-content-match", + candidates: [], + }); +}; + const candidatesWithLocation = ( entity: RuntimeReconciliationEntity, index: CandidateIndex, diff --git a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeContentPriority.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeContentPriority.test.ts new file mode 100644 index 0000000000..7e46294aa3 --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeContentPriority.test.ts @@ -0,0 +1,231 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { + parseRuntimeReconciliationInput, + reconcileJavaScriptRuntime, +} from "../../../../src/domain/javascript/javascriptRuntimeReconciliation.js"; +import { reconcileRuntimeEntities } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationMatching.js"; +import { classifyStaticLoadStates } from "../../../../src/domain/javascript/javascriptRuntimeLoadState.js"; +import { projectRuntimeCaptures } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationRuntime.js"; +import { reconcileJavaScriptRuntimeInputSchema } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { collectStaticRuntimeCandidates } from "../../../../src/domain/javascript/javascriptRuntimeStaticCandidates.js"; +import { + SOURCE, + analyzeFixture, + applicationFixture, + electronRuntimeEvidence, +} from "../../../fixtures/javascriptRuntimeReconciliation.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const CAPTURED = "export const observed = 'different';\n"; + +const collisionInput = async ( + alternatives: number, + explicitMapping: boolean, +) => { + const root = await applicationFixture(); + if (alternatives > 0) await writeFile(join(root, "other.js"), CAPTURED); + const live = explicitMapping + ? await createTestTempDirectory("rea-runtime-content-live-") + : root; + const extra = await createTestTempDirectory("rea-runtime-content-extra-"); + await writeFile(join(extra, "app.js"), CAPTURED); + return parseRuntimeReconciliationInput( + reconcileJavaScriptRuntimeInputSchema.parse({ + static_layers: [ + { + role: "application", + analysis: await analyzeFixture(root), + runtime_mappings: explicitMapping + ? [{ kind: "file-root", root: live, artifact_prefix: "" }] + : [], + }, + ...(alternatives > 1 + ? [{ role: "assets", analysis: await analyzeFixture(extra) }] + : []), + ], + runtime_observations: [ + electronRuntimeEvidence(live, CAPTURED, { includeWorker: false }), + ], + }), + ); +}; + +it.each([ + [0, false], + [1, false], + [2, false], + [0, true], + [1, true], + [2, true], +] as const)( + "preserves mapped disagreement with %i other content matches (explicit mapping: %s)", + async (alternatives, explicitMapping) => { + const parsed = await collisionInput(alternatives, explicitMapping); + const result = reconcileJavaScriptRuntime(parsed); + const script = result.reconciliations.find( + ({ entity_kind }) => entity_kind === "script", + ); + expect(script).toMatchObject({ + status: "unmatched", + basis: explicitMapping ? "operator-file-mapping" : "artifact-path", + confidence: "high", + reason: "captured-content-disagrees-with-static-location", + static_layer_id: null, + static_node_id: null, + candidate_static_count: 1, + }); + const candidate = script?.candidate_static_nodes[0]; + const selected = result.graph.nodes.find( + ({ node_id }) => node_id === candidate?.static_node_id, + ); + expect(selected?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ path: "app.js" }), + }), + ]), + ); + expect( + result.graph.edges.some( + (edge) => + edge.relation === "observed_as" && + edge.target_node_id === script?.runtime_node_id, + ), + ).toBe(false); + }, +); + +it("restricts content-and-location ambiguity to mapped candidates", async () => { + const application = await applicationFixture(); + const cache = await applicationFixture(); + const other = await applicationFixture(); + const live = await createTestTempDirectory("rea-runtime-content-mapped-"); + const analysis = await analyzeFixture(application); + const mapping = [{ kind: "file-root", root: live, artifact_prefix: "" }]; + const parsed = parseRuntimeReconciliationInput( + reconcileJavaScriptRuntimeInputSchema.parse({ + static_layers: [ + { role: "application", analysis, runtime_mappings: mapping }, + { + role: "cache", + analysis: await analyzeFixture(cache), + runtime_mappings: mapping, + }, + { role: "assets", analysis: await analyzeFixture(other) }, + ], + runtime_observations: [ + electronRuntimeEvidence(live, SOURCE, { includeWorker: false }), + ], + }), + ); + const result = reconcileJavaScriptRuntime(parsed); + const script = result.reconciliations.find( + ({ entity_kind }) => entity_kind === "script", + ); + expect(script).toMatchObject({ + status: "ambiguous", + reason: "ambiguous-static-candidates", + candidate_static_count: 2, + }); + const unmapped = result.static_layers.find(({ role }) => role === "assets"); + expect( + script?.candidate_static_nodes.map( + ({ static_layer_id }) => static_layer_id, + ), + ).not.toContain(unmapped?.layer_id); +}); + +it.each([false, true])( + "retains digest-only matching without a mapped candidate (mapping: %s)", + async (mapped) => { + const root = await applicationFixture(); + await writeFile(join(root, "other.js"), CAPTURED); + const live = await createTestTempDirectory( + "rea-runtime-content-unlocated-", + ); + const result = reconcileJavaScriptRuntime( + parseRuntimeReconciliationInput( + reconcileJavaScriptRuntimeInputSchema.parse({ + static_layers: [ + { + role: "application", + analysis: await analyzeFixture(root), + runtime_mappings: mapped + ? [{ kind: "file-root", root: live, artifact_prefix: "" }] + : [], + }, + ], + runtime_observations: [ + electronRuntimeEvidence(live, CAPTURED, { + scriptFile: "absent.js", + includeWorker: false, + }), + ], + }), + ), + ); + expect( + result.reconciliations.find( + ({ entity_kind }) => entity_kind === "script", + ), + ).toMatchObject({ + status: "matched", + basis: "content-sha256", + confidence: "high", + reason: "unique-content-match", + candidate_static_count: 1, + }); + }, +); + +it("keeps unavailable mapped digests unknown instead of borrowing unrelated matches", async () => { + const parsed = await collisionInput(1, true); + const candidates = collectStaticRuntimeCandidates(parsed.layers).map( + (candidate) => + candidate.paths.includes("app.js") && candidate.category === "javascript" + ? { ...candidate, digests: [] } + : candidate, + ); + // Model the matcher's supported missing-digest boundary; do not falsify Evidence. + const runtime = projectRuntimeCaptures(parsed.captures); + const result = reconcileRuntimeEntities({ + entities: runtime.entities, + candidates, + layers: parsed.layers, + }); + expect( + result.items.find(({ entity_kind }) => entity_kind === "script"), + ).toMatchObject({ + status: "unknown", + basis: "operator-file-mapping", + confidence: "unknown", + reason: "static-or-runtime-coverage-incomplete", + candidate_static_count: 1, + }); + const script = result.items.find( + ({ entity_kind }) => entity_kind === "script", + ); + const candidate = script?.candidate_static_nodes[0]; + const states = classifyStaticLoadStates( + parsed.layers, + runtime.entities, + result.items, + { + reconciliationComplete: true, + }, + ); + expect( + states.states.find( + (state) => + state.static_layer_id === candidate?.static_layer_id && + state.static_node_id === candidate?.static_node_id, + ), + ).toMatchObject({ + status: "unknown", + reason: "static-or-runtime-coverage-incomplete", + }); +}); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts index ca747c90c0..643b8dcdd4 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts @@ -224,29 +224,6 @@ it("keeps static load states unknown when one scoped capture omits scripts", asy ); }); -it("reports a captured digest disagreement instead of accepting a path match", async () => { - const fixture = await applicationFixture(); - const staticEvidence = await analyzeFixture(fixture); - const runtimeEvidence = electronRuntimeEvidence( - fixture, - "export const observed = 'different';\n", - ); - - const result = reconcileInput({ - static_layers: [{ role: "application", analysis: staticEvidence }], - runtime_observations: [runtimeEvidence], - }); - const script = result.reconciliations.find( - ({ entity_kind: kind }) => kind === "script", - ); - - expect(script).toMatchObject({ - status: "unmatched", - reason: "captured-content-disagrees-with-static-location", - }); - expect(script?.candidate_static_nodes).toHaveLength(1); -}); - it("reconciles active Electron as a partial target-only runtime capture", async () => { const fixture = await applicationFixture(); const staticEvidence = await analyzeFixture(fixture);