From 5e79d468e5864204cbe79a7ca2898541cee5e012 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:49:15 +0800 Subject: [PATCH] fix(javascript): retain runtime candidate ambiguity Keep referenced ambiguous candidates unknown instead of classifying them as absent. Refs: #3192 --- .../javascript/javascriptRuntimeLoadState.ts | 11 ++ .../javascriptRuntimeReconciliationSchemas.ts | 1 + tests/boundary/cli/electronCli.test.ts | 121 ++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/src/domain/javascript/javascriptRuntimeLoadState.ts b/src/domain/javascript/javascriptRuntimeLoadState.ts index 838f606f64..d2d2cab43a 100644 --- a/src/domain/javascript/javascriptRuntimeLoadState.ts +++ b/src/domain/javascript/javascriptRuntimeLoadState.ts @@ -46,6 +46,7 @@ const classifyLayer = ( ): { readonly states: JavaScriptStaticLoadState[] } => { const nodes = layer.graph.nodes.filter(isLoadStateNode); const direct = new Map(); + const ambiguous = new Set(); for (const match of matches) if ( match.status === "matched" && @@ -53,6 +54,10 @@ const classifyLayer = ( match.static_node_id !== null ) append(direct, match.static_node_id, match.runtime_node_id); + else if (match.status === "ambiguous") + for (const candidate of match.candidate_static_nodes) + if (candidate.static_layer_id === layer.layerId) + ambiguous.add(candidate.static_node_id); const resident = residentNodes(layer, new Set(direct.keys())); const runtimeScope = createStaticRuntimeScope(layer, runtimeEntities); const scopedEntities = runtimeEntities.filter( @@ -77,6 +82,12 @@ const classifyLayer = ( runtimeNodeIds: [], reason: "containing-asset-was-loaded", }); + if (ambiguous.has(node.node_id)) + return state(layer, node, { + status: "unknown", + runtimeNodeIds: [], + reason: "ambiguous-runtime-candidate", + }); const nodeWithinScope = staticNodeWithinRuntimeScope(node, runtimeScope); if (layerComplete && nodeWithinScope) return state(layer, node, { diff --git a/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts b/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts index 684a4a4d2c..976b82dbe5 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts @@ -228,6 +228,7 @@ const staticLoadStateSchema = z.strictObject({ "not-observed-in-bounded-capture", "layer-outside-runtime-scope", "static-or-runtime-coverage-incomplete", + "ambiguous-runtime-candidate", ]), }); diff --git a/tests/boundary/cli/electronCli.test.ts b/tests/boundary/cli/electronCli.test.ts index 6ba962e9b4..8898ad9004 100644 --- a/tests/boundary/cli/electronCli.test.ts +++ b/tests/boundary/cli/electronCli.test.ts @@ -13,6 +13,12 @@ import { type FakeCdpBrowser, } from "../../fixtures/fakeCdpBrowser.js"; import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js"; +import { + SOURCE, + applicationFixture, + analyzeFixture, + electronRuntimeEvidence, +} from "../../fixtures/javascriptRuntimeReconciliation.js"; import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; const execute = promisify(execFile); @@ -219,8 +225,123 @@ describe("Electron CLI runtime reconciliation", () => { }, INTEGRATION_TEST_TIMEOUT_MS, ); + + it( + "keeps ambiguous runtime candidates unknown while unrelated scoped assets remain absent", + async () => { + const application = await applicationFixture(); + const assets = await createTestTempDirectory("rea-runtime-cli-assets-"); + const runtime = await createTestTempDirectory("rea-runtime-cli-live-"); + const inputPath = join(assets, "reconciliation.json"); + temporary.push(application, assets, runtime); + await Promise.all([ + writeFile(join(assets, "app.js"), SOURCE), + writeFile(join(assets, "absent.js"), "export const absent = true;\n"), + writeFile(join(runtime, "app.js"), SOURCE), + ]); + const rootMapping = [{ kind: "file-root", root: runtime }] as const; + const input = { + static_layers: [ + { + role: "application", + analysis: await analyzeFixture(application), + runtime_mappings: rootMapping, + }, + { + role: "assets", + analysis: await analyzeFixture(assets), + runtime_mappings: rootMapping, + }, + ], + runtime_observations: [ + electronRuntimeEvidence(runtime, SOURCE, { includeWorker: false }), + ], + }; + await writeFile(inputPath, JSON.stringify(input)); + const reconciled = await runCli( + ["reconcile-javascript-runtime", inputPath, "--json"], + process.env, + ); + + const normalized = + isRecord(reconciled) && isRecord(reconciled.normalized_result) + ? reconciled.normalized_result + : undefined; + const reconciliations = normalized?.reconciliations; + const loadStates = normalized?.static_load_states; + if (!Array.isArray(reconciliations) || !Array.isArray(loadStates)) + throw new Error("Missing runtime reconciliation output"); + const ambiguous = reconciliations.find( + (item) => + isRecord(item) && + item.status === "ambiguous" && + item.entity_kind === "script", + ); + if ( + !isRecord(ambiguous) || + !Array.isArray(ambiguous.candidate_static_nodes) + ) + throw new Error("Missing ambiguous runtime script"); + const candidateIds = ambiguous.candidate_static_nodes.flatMap((item) => + isRecord(item) && typeof item.static_node_id === "string" + ? [item.static_node_id] + : [], + ); + expect(candidateIds).toHaveLength(2); + const candidateStates = loadStates.filter( + (item) => + isRecord(item) && + typeof item.static_node_id === "string" && + candidateIds.includes(item.static_node_id), + ); + expect(candidateStates).toHaveLength(2); + expect( + candidateStates.every( + (item) => + isRecord(item) && + item.status === "unknown" && + item.reason === "ambiguous-runtime-candidate", + ), + ).toBe(true); + expect(loadStates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "not-observed-in-capture", + reason: "not-observed-in-bounded-capture", + }), + ]), + ); + + const singleLayerPath = join(application, "single-reconciliation.json"); + await writeFile( + singleLayerPath, + JSON.stringify({ + static_layers: [input.static_layers[0]], + runtime_observations: input.runtime_observations, + }), + ); + const uniquelyMatched = await runCli( + ["reconcile-javascript-runtime", singleLayerPath, "--json"], + process.env, + ); + expect(uniquelyMatched).toMatchObject({ + normalized_result: { + static_load_states: expect.arrayContaining([ + expect.objectContaining({ + status: "loaded", + reason: "runtime-script-correspondence", + }), + ]), + }, + }); + }, + INTEGRATION_TEST_TIMEOUT_MS, + ); }); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + const runCli = async ( arguments_: readonly string[], environment: NodeJS.ProcessEnv,